small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo

web client #

web/ is the browser client: a canvas (Svelte 5, Svelte Flow) where sessions open as panels. It is a client like the TUI, not an extension; it reads the same daemon HTTP API.

albedo web (cli/internal/cli/web.go) finds or starts the daemon, serves web/dist (or ALBEDO_WEB_DIST, which the Nix package sets to its web build, .#albedo-web: buildNpmPackage over web/, so bump npmDepsHash in nix/packages/web.nix when package-lock.json changes) on 127.0.0.1:4317 (--port, 0 for any) and, when this machine is on a tailnet, on its tailnet address too (below), prints a launch link and opens it unless --no-open or ALBEDO_NO_BROWSER is set, then returns after the detached server is ready. Repeating the command reuses the server and link; changing the listen options requires --stop first. --stop stops it, --new-link replaces its sign-in key and signs existing browsers out, and --foreground runs the server in the calling terminal until interrupted. The detached process is not supervised: it does not restart after a crash or reboot. cli/internal/webui owns the boundary:

  • Each browser tab multiplexes its session panels and root-list follow over one WebSocket at /api/events, avoiding the HTTP/1.1 six-connection SSE limit across tabs. stream.ts sends new follows immediately on an open socket, preserves each session cursor independently, and resubscribes an ended follow. Unsubscribe sends a stop only for a follow actually sent. Hidden tabs stop chat follows but keep one root-list follow for completion alerts; without that background follow they close pending and open sockets and reopen on return. Stale socket callbacks cannot revive them. Older daemons fall back to the existing SSE routes, retaining their connection budget and visibility cancellation. Both the Go proxy and Vite forward WebSocket upgrades with the daemon bearer token.
  • The daemon token never reaches the browser. The page calls /api/... on its own origin; the proxy strips /api, drops Origin, Cookie and Referer, and adds Authorization: Bearer <token>. Session streams pass through unbuffered.
  • The launch key persists in $ALBEDO_HOME/web-key across stops and restarts. /?key=<key> sets an HttpOnly, SameSite=Strict cookie that lasts 400 days (the longest browsers keep one), so a browser stays signed in after it closes, and redirects to /; /api without that cookie answers 401 web_signed_out. --new-link revokes the previous key and cookies.
  • Requests whose Host is not 127.0.0.1, localhost, a shared address, an answered tailnet name or the published origin's host are refused (DNS rebinding), and so is an Origin that is neither the request host's own, whatever scheme it arrived with (a TLS terminator makes the page https while the server speaks http), nor the published origin (Server.ownOrigin). The Origin comparison loses the default port on both sides, so Host: name:443 and Origin: https://name are the same origin; any other port still tells two pages apart.
  • --allow <network> (repeatable; CIDR, or an IPv4 address whose trailing zero octets stand for any, so 10.0.0.0 is /8 and 100.64.0.0 is /16; webui.ParseNetwork) shares it with other devices: it listens on 0.0.0.0, answers only on this machine's addresses inside an allowed network (also accepted as Host) and only to clients from one or from loopback (403 otherwise), and prints a launch link per address. Devices still sign in with the key. --save stores the given networks in the daemon's ui.web_allow setting, which a later albedo web without --allow uses; --save alone clears it.
  • On a machine signed in to Tailscale (tailscale status --json reports Running; webui.CurrentTailnet), albedo web with no --allow and nothing saved shares to 100.64.0.0/10 (webui.TailscaleNetwork) as if it had been given, and Server.Answer also accepts the machine's MagicDNS name (full and short; the suffix is the tailnet's own, never assumed to be ts.net) as Host, case-insensitively. It prints on your tailnet: <full name> beside the per-address links. When stdin and stdout are a terminal it then draws a QR code (cli/internal/qr: a small in-repo encoder, byte mode, level L, versions 1-10, half-block rendering in explicit black on white) of the first link another device can reach: the tailnet name, else the first shared address; a loopback-only run prints none. Tailnet traffic is authenticated device to device and the launch key is still required. Without a tailscale CLI or a running tailnet it is loopback-only as before. --allow or a saved ui.web_allow replaces the default; --local (exclusive with --allow, ignores the saved networks) serves this machine only. --allow takes networks, not device names: a device's own address is never one of this machine's.
  • --public-url https://albedo.example.ts.net names the origin a proxy in front serves this listener through: the printed link and QR carry it instead of loopback's http, and Server.Publish answers its host and accepts it as the page's own Origin, so a proxy may pass the public name or loopback as Host (webui.ParseOrigin lower-cases the host, leaves out a default port, and refuses a path, query or fragment). --tailnet-https gets that origin from Tailscale: webui.Tailnet.ServeHTTPS first requires CertDomains in tailscale status --json to cover the machine's MagicDNS name (on Tailscale, HTTPS certificates in the admin console; on Headscale, dns.certificates), and refuses before running any serve command otherwise. Serve.Claim then points tailscale serve's https:443 root at this listener, or reuses a root already pointing here, reading tailscale serve status --json rather than its text; it refuses a root that publishes anything else or that Funnel opens to the internet, and removes a handler it created but could not confirm. The command that set the handler removes it on Ctrl-C, a failed start and --stop, only while it still points at this listener (Serve.Release). Each tailscale command has 5 s; a background start or stop gets 30 s (webGrace), both ending the server with SIGTERM so its handler comes down before a SIGKILL. Both flags are recorded in web.json (public, tailnet_https), so a later albedo web reprints the same link and --new-link resumes them. test/e2e/web_test.py drives this through a fake tailscale on PATH.
  • A transport failure or a 401 from the daemon triggers Connection.Refresh, so a restarted daemon is found on the next request.
  • /assets/* (content-hashed by Vite) is cached as immutable; everything else is no-cache.
  • /frame/... forwards artifact files to the daemon without requiring the web sign-in cookie. It permits GET/HEAD, and POST only to .albedo/state and .albedo/errors, which it forwards, and .albedo/message, which it answers itself (message.go): a page in a tab of its own posts its chat messages there, and albedo web sends them to the artifact's session after checking the key and the loop guard (robot-docs/artifacts.md). The GET .albedo/version long-poll a latest/ tab waits on passes like any file, without a timeout. It strips browser Cookie, Origin and Referer, adds the daemon bearer token, streams the response, and applies the artifact sandbox/CORS/referrer/nosniff headers to every response.
  • While serving, the command atomically owns $ALBEDO_HOME/web.json (pid, actual port, url, names, addresses, allow, local, public, tailnet_https). A second live server leaves the existing manifest alone and removes only a manifest that still names its own pid. Sharing to a network (the tailnet by default) listens on every address, which binds beside a server already on 127.0.0.1 (Go sets SO_REUSEADDR) while that one keeps answering loopback, so before listening it claims 127.0.0.1:<port> once and refuses to start when another server holds it.

In development, npm run dev in web/ proxies /api the same way from $ALBEDO_HOME/daemon.json, read once at start (only when serving, so a build needs no daemon), dropping Origin and Cookie. WebSocket upgrades also drop Referer and require the page's own origin when an Origin header is present. It listens on the local network, open, so other devices at home can try the client; the Host it answers is limited to the machine's own name (bare and .local) and Tailscale's *.ts.net, since the proxy holds the token and a rebound name must not reach it.

Background alerts (attention.ts, notifications.svelte.ts, sessions.svelte.ts): the tab title counts distinct watched sessions that finished a turn, acquired a blocking reason, or received agent mail while the tab was hidden or unfocused; the favicon gets a small dot. A watched session is pinned, on any open board, or the phone's current chat, excluding archived sessions. Root-list activity supplies busy-to-idle transitions; a changed latest-answer id also catches a turn missed while disconnected. Mail marks the watched recipient, not the sender: task dispatch and system event mail do not count. Unwatched child turns stay quiet. The existing roots collection supplies these events; no per-child follows or daemon changes are needed. A closed board alone does not subscribe its sessions.

Returning to the tab (focus or becoming visible) clears the count and closes its system notifications. The settings switch asks for Notification permission only on a click and saves its opt-in in this browser, off by default. Denial, unavailable APIs and notification construction failures leave tab counts working. System notifications appear only while hidden, once per unread session, contain the session name and event kind (not message contents), and focus the tab and reveal its chat on click, reusing an open board before adding a window. Hidden tabs keep only the collection follow, including SSE fallback; chat/history reads still pause. If that follow is offline, the list is retried every 30 seconds. Browsers may suspend background tabs, so these are best-effort alerts, not push notifications.

Words: a board is a named canvas of panels, and the open boards are the tabs along the top (boards.svelte.ts). A board holds sessions from any folders. Workspace keeps its albedo meaning, a session's folder, and the sidebar shows each session's folder under its name. Its sections are headed as the terminal heads them, the label in the text colour, its count faint, then a thin rule to the edge (the one place lines separate, as sectionRule does in the TUI); its rows alternate as the phone's list does, every other row in a section on --rail-stripe (the phone's step from its ground, taken from the rail's, about 1.06:1), hover lays a step over a row's own tone, and a session open on the board sits on --open-tint, the rail with a flat step of the accent (12% in light, 18% in dark), opaque so the stripes don't show through: a hue over the stripes' tones, under hover's step, and open rows that meet square their corners so a run of them reads as one block. [ (shown on the sidebar's toggle, as search shows ⌘K; those key chips sit a 12% step of ink above their button) hides or shows the sidebar; hidden, it pops out floating over the canvas, without animation, as soon as the pointer is within 20 px of the canvas's edge on its side, or further out the faster it is heading there (15 px more per px/ms, up to 50 px), so a flick toward the edge opens it early (App.svelte, watched from the canvas so the edge still pans). It stays while the pointer is on it or within 48 px of it, the top bar above it included, or while its filter has focus, and goes 100 ms after the pointer is clearly elsewhere or leaves the page; / brings it out the same way with the filter focused (Escape clears it and puts it away), and only focuses the filter when the sidebar is shown. + makes a new board; double-click or F2 renames one; closing a tab keeps the board, which the palette reopens (Open board: <name>); Delete board removes it. The last tab cannot be closed, so there is always a canvas.

A board is an open canvas. Windows sit anywhere on it; the view pans freely and zooms out to an overview. Svelte Flow draws, drags and resizes the windows; board.svelte.ts places new ones (right of the selected window, top-aligned, stepping past overlaps) and owns the viewport. The wheel is handled in Canvas.svelte rather than by Svelte Flow, because windows and canvas share the trackpad: a pinch (ctrl-wheel) zooms around the pointer anywhere, a mouse wheel's notch with ctrl or ⌘ by one 10% step (a notch is a jump of 50 px or more, or any line-mode delta, where a pinch moves a few pixels at a time), an up-down scroll over a window that can scroll scrolls it while the zoom is readable (60% and up), so does a sideways scroll over anything under the pointer that scrolls sideways (a diff's code inside Pierre's shadow root, a wide table; found through composedPath()), a menu floating over the canvas (MenuButton's list) or a view that pans and zooms itself (the agents tree), marked holds-wheel, keeps every scroll over it at that zoom even when it has nothing to scroll, and every other scroll pans. Dragging empty canvas pans; holding Space (unless a text field has focus) or the middle button drags the canvas from anywhere, over windows too, taking the pointer before Svelte Flow or the window sees it; holding Alt (Option), a drag anywhere on a window or note moves it, not only by its title bar, without selecting text, and the click that ends it is swallowed (Option-click would download a link), and Space with a double-click anywhere glides the view there, centred at 90% (Board.centreOn, 300 ms from the middle of the pane up to 500 ms from a corner), where a plain double-click on empty canvas leaves a note. + and − zoom in and out by 5 points (the buttons too), ⇧1 fits all windows, ⇧0 returns to 100%, M shows an overview map, ⌥←/⌥→ moves between windows. The view controls (zoom, fit, map) in the bottom-right corner, under the overview map when it shows (as wide as the controls, measured, and showing and fading with them), and key hints in the bottom-left as the terminal's footer lists them (spc and a grab to pan, ⌥ and a move to move a window, cmd and a scroll to zoom, ⇧1, ⌥←→, spc and two clicks to zoom at a spot, two clicks for a note; the mouse gestures are Lucide's icons, drawn in Gesture.svelte, to keep them short; keys in the text colour, what they do muted, each pair on a surface of its own with a small gap between), fade in while the view moves and fade out 4 s after it stops; hovering or focusing them keeps them, and once faded they let clicks through to the windows beneath. What a window shows is a view, each framed by nodes/WindowFrame.svelte: a session's chat, or a review of one turn's changes; files, terminals and agent-made pages are meant to follow. A view opened from a window names it as its parent, opens beside it, and the canvas draws a faint dashed line between them (the board derives Svelte Flow edges from those parents; every window carries invisible handles for them).

The review (nodes/ReviewNode.svelte, review.ts) opens from "review N files +a −r" on a turn's sign-off, or from the +a −r on any work line, which also scrolls it to the first file that stretch of work changed (data.jump). It needs no daemon support beyond history: every tool call's trace records each file it changed, with the unified diff (changes[].diff, or reason when the file was binary, unreadable or too large). Writes the harness makes for itself stay out of it (albedo_trace.unobserved()): the browser plugin's profile lock and marker and its auto-named screenshots (in the cache, see kernel) among them; a screenshot saved to a path the cell chose is still the cell's change. The window keeps the session, the turn and the turn's first history position, reads that turn's entries (after=, until the next turn starts), hydrates their traces, and shows the files with their counts, then each file's diffs in order. Diffs are drawn by @pierre/diffs (Shiki highlighting, pierre-dark/pierre-light tokens over the canvas's own ground and diff colours; views/DiffView.svelte), loaded with the first review so the rest of the client stays small. The margin button beside a line (drag for several) leaves a note on the canvas beside the window, tied to those lines; "send review" sends every unsent note tied into the review as one message, after an optional overall line. While the session works, the review reads what came after the entries it has every 2.5 s, and once more when it stops (readTurn without seek ends at the first entry of another turn, so an older turn's review costs one page a read); results already filled in are kept, a file whose changes grew glows in the index for a moment, and the review follows its end as new changes arrive while the reader is at the bottom.

The terminal (nodes/TerminalNode.svelte, views/TerminalView.svelte, jobs.svelte.ts) shows one background job's output, read-only. A terminal icon in a chat's title bar (JobsButton.svelte, on the phone above the composer, shown once the session has had a job; it and the artifacts icon share MenuButton.svelte) counts the running jobs and lists them, finished ones with their exit codes while the kernel keeps their output; the list polls GET /extensions/run/sessions/{id}/jobs/recent every 2 s while a job runs, 6 s otherwise. Choosing one opens a window beside the chat, tied to it like a review. The window opens on the last page of .../jobs/{job}/output and follows next_offset every 700 ms while the job runs (once more after it ends), keeps at most 400,000 characters, drops colour codes and shows a \r-redrawn line as its last draw. Its stop button asks twice, then posts .../jobs/{job}/stop. A job the kernel no longer has (a restarted kernel) says so. On a phone the same view opens in the chat's dock, above the composer.

The trace (nodes/TraceNode.svelte, views/TraceView.svelte, trace.ts, TraceTie.svelte, tracelink.svelte.ts) shows one task as albedo ran it: "trace" on the sign-off of a turn that ran tools opens it beside the chat, tied to it like a review. A task is that turn and the turns after it until the person types again (mail from a child, continuations), so a parent's wait for its agents and their replies fall inside it. A trace opened on the session's newest task follows the session instead: every later turn is added as it begins, each message the person sent marked on the axis ("you"), and it reads again whenever the session's phase changes as well as every 3 s while anything in it works, never while the tab is hidden, and every 5 s while a read fails. Its axis runs ahead of now (a dashed line), by a quarter of the task or at least 30 s, and grows only when now reaches the end, so the calls already drawn hold still under the pointer between reads. "earlier turn" adds the turn before the earliest shown (turnBefore walks history back to the person's previous message; the count is saved on the window as earlier), read in place so the view keeps its zoom.

The trace is read in parallel where it can be: agents, jobs and model calls start with its history, and the session's own lane shows before its agents are read. Each window keeps per-lane history positions, request sequences and known children. Live reads append new entries and calls, close pending tools, and reuse completed child lanes until their activity changes; running jobs and child summaries are reconciled. A followed child keeps growing while its parent waits. Hydrated inputs and traces are kept, with a tool’s later result fetched separately. Earlier-turn changes and failed reads discard the cursors. Initial model-call reads use since_ms to skip requests that finished before the task; a daemon rejecting it with 400 is detected once, then uses the older parallel single-row probing search. Later reads use request sequences on either daemon. It animates only transform and opacity, and nothing animates while it is out of view.

Rows come from three places: model calls from the session's request records (GET /sessions/{id}/context?view=requests, oldest first; since_ms finds the task’s first records, with single-row probes only on older daemons), tool calls from the transcript (a call's entry to its result's; input a history page only referenced is read afterwards in one history/content walk per agent), and agents from GET /sessions?parent_id=, three deep, kept when they were alive during the task. Each agent is one lane: its calls never overlap, so model calls (grey, the wait for the first byte lighter) and tool calls (the lane's hue, which keeps clear of red; failures are red) take turns along one strip, and a gap of 1.5 s or more while an agent below works is hatched as waiting. Under the strip, each tool call has one chip naming the most telling thing it did, read from its trace (a web search, then an MCP call, the computer, an artifact, an edit, a command, a search, a read, a listing, a display; the first line of its code when it did none of them), with the rest counted ("+3") and listed on hover and in its drawer. Calls closer than a readable chip (64 px) share one cluster chip that counts their kinds (as many as fit) and zooms to its stretch when pressed; clusters are worked out again as the zoom changes, and no chip runs past the next, so none overlaps. Jobs are packed by where they end on screen, at least 18 px wide, for the same reason. A fold's idle label stays off the ticks, and gives way to the person's "you" when their message ended it. Under the chips, the lane's background jobs (GET /extensions/run/sessions/{id}/jobs/recent, kept only while the kernel keeps them) on the same clock, packed into as few tracks as keep them apart; tracks past the first fold into a thin band of ticks, and pointing at them floats them out over the lanes below, so opening them never moves the layout. A running job shimmers, one that failed or timed out is red, one started as a service is dashed, and a click opens its terminal beside the trace. Jobs never keep idle time from folding. The gutter gives each lane's model and how much context its last call sent, and what share came from the cache; a compaction summary's call is drawn in the accent. Calls are often a few pixels wide, so the strip is taller than its bars and finds the call itself (callAt): the nearest within 10 px, a tool call close by beating the model call under the pointer. Spawn arrows leave the parent's tool call running when the child was created; reply arrows leave the child's last step before its mail landed. Above the lanes, one bar says where the task's own time went (spent): thinking, running code, waiting on agents, waiting on the person (an idle stretch their message ended), and the gaps between steps, with the duration, tokens, calls and failures beside it; all of it follows the playhead.

A press on a call opens a drawer under its lane: what it did (each edit opens the review of its turn at that file, through onreview), its code and output (read then, in one batch), or a model call's first byte, tokens sent, cache share and tokens written; ← and → step along the lane, Esc closes. Hovering a tool call in either the trace or the chat lights it in both (traceLink.entry, keyed by the chat's entry: the result once there is one), and TraceTie draws a line between them over the canvas, pinned to the chat's edge when the call is scrolled out of view; opening one in the trace sets traceLink.reveal, which opens its stretch in the chat and scrolls it there. Any stretch of 20 s or more with nothing running is folded into a 2.5 s break labelled with how long it was idle (clockOf, a polylinear d3 scale); ticks are round elapsed times within each busy stretch. Zooming, panning and replaying only move CSS variables (--w the axis width, --left the pan, --t the playhead), so none re-renders a bar: a pinch zooms at the pointer, a sideways swipe pans (the canvas leaves both to anything inside .zooms-itself while the window is readable), a press on the axis scrubs, a double-click on it or "fit" shows the whole task, a double-click on a call zooms to it, and the replay plays any task in about 12 s. Search matches a call's name, the first 4000 characters of its input and its error; Enter opens each match in turn. prefers-reduced-motion drops every animation but the replay.

Agents (AgentsButton.svelte, agents.ts, nodes/AgentsNode.svelte, views/AgentsView.svelte): once a session has agents below it, its window shows a branching-icon button beside the jobs one (in canvas windows only; the phone has none). Its count is the agents working, and its menu lists them (oldest first, closed ones too, indented by how far below the window's own session they are) under an "open tree" row. A family is read whole from its root with GET /sessions?family_id= (listFamily), once for every window of it, however many show it (watchFamily shares the read and the newest answer). It is polled, since the events socket follows only roots, and familyDelay sets the pace: every 2 s while an agent works, 3 s while the root has a turn, 6 s at rest with agents and 30 s with none, at once when the root's turn starts or ends (pokeFamily) or the tab comes back, and never while the tab is hidden. A read that changes nothing tells no one, and a first read that fails says so. An agent picked opens as an ordinary chat window beside the one it came from, tied to it by a tether (Board.openSession(id, parent)). "open tree" opens the agents window for the family's root (layoutTree): active branches grow to the right of the root while closed folds and finished branches balance to its left when active work is also present; large leaf sibling sets (> 5) stagger across sub-columns with a half-row vertical offset so links glide between rows, and two or more closed leaf agents under a parent fold into a ▸ N closed agents deck node that unfolds its compact closed pills in place on click without shrinking the fitted zoom (fitBox and shapeOf ignore unfolded closed pills, and clicking a fold pins it under the pointer while contour tucking keeps sibling branches from overlapping). Active cards show their name, short model or live face (on roots and leads), and latest tool/activity snippet; closed pills use an opaque dimmed surface, and links sit at 22% opacity at rest and light the hovered card's ancestor chain in --accent (90%) while stepping down monotonically across descendant hops (hoverLineage: 62%, 42%, 30%, vs 14% for unrelated branches). A click opens an agent, and a "hide inactive" button (shown once any agent is not working) leaves out the agents that are not working, idle and closed alike, unless one is working below them (withoutInactive), the window shrinking to what is left; the choice is kept in this browser. The tree pans and zooms itself: it starts fitted and centred with room around it (48 px, enlarged up to 135% when the window is bigger than it needs) and follows the window while it is resized, until the person drags the ground, scrolls, pinches or uses its + and − buttons, after which it stays where they put it until the fit button follows the window again; an "auto-fit" button (on by default, kept in this browser) also fits it whenever the tree's shape changes (shapeOf: an agent added, removed or hidden, not one only doing something else), and the window itself is sized to the tree either way until it is resized. The window is sized to hold the tree at 115% and its room (AgentsNode, at most 1500 by 960), and again when agents come or go, growing only into free room (growInto: it stops 32 px short of a window beside or below it, so it never covers the chats opened from it), until the person resizes it. The size it was last fitted to is kept on the window (data.fit, saved with the board), and it is fitted again only while it still has that size (refit), so a size the person set holds through a reload. The session list holds only roots (scope=roots), so a window of an agent keeps its record in sessions.agents (adopt when it opens, refreshed by each family read, and ensure reads it back for a window the board restores, counting only a 404 as gone).

The artifact (nodes/ArtifactNode.svelte, views/ArtifactView.svelte, artifacts.svelte.ts; robot-docs/artifacts.md) is a page the agent made, live in a sandboxed <iframe> (no allow-same-origin) on /frame/{id}.{key}/{version}/{entry}. The chat learns of one from the artifact activity in a cell's trace: a line under the turn's summary (made counter, updated counter v2) opens it, and one that arrives while the chat is open (Transcript.made, never history read on opening) opens a window beside the chat, tied to it like a review; the view moves only when that chat is the selected window, and then shows both (Board.revealTogether, zooming out no further than 60%). An open window follows each new version in place unless the version menu pinned it; a window the reader closed stays closed. Its new-tab button opens /frame/{id}.{key}/latest/ while it follows, a tab that reloads itself as the agent revises the page (robot-docs/artifacts.md, the bridge), and the pinned version's own address while pinned. The board saves the artifact's id, its session, the pinned version and the newest version heard of, never the page. The window answers the page's bridge (PageHost): the theme and the client's colours resolved to plain values (themeVariables, sent again when the theme or palette changes), the page's messages to the chat headed From the artifact “<title>” (v<n>):, with the loop guard (five a minute without a click behind each; the rest wait behind send and discard), failures shown under the page with "ask albedo to fix it", and a pinch over the page replayed on the frame so it zooms the canvas. While the canvas is being panned or a window carried, or below 60% zoom, the frame lets the pointer through. On the canvas the page is zoomed, not scaled (ArtifactView): the frame gets CSS zoom at the canvas's zoom and scale(1/zoom), so its own document lays out at the window's width and draws at the zoom's resolution while its surface reaches the screen untransformed. Chrome draws a scaled frame from another process (the sandboxed page always is one) from a stale or low-resolution raster while it scrolls and after a pinch, then sharpens a moment later; zoomed, there is no scaled surface to resample. Each zoom change re-rasters the page, so mid-zoom the frame catches up only when its zoom is more than a tenth off the canvas's (DRIFT), and takes it exactly once the canvas holds still for 40 ms (SETTLE); the page is scaled the rest of the way in between. The page sees a devicePixelRatio of the screen's times the zoom; PageHost maps points through the frame's on-screen size against its clientWidth, which already covers it. On a phone the artifact opens in the chat's dock, sized to its page. The palette finds artifacts by title, and open an artifact… and delete an artifact… list every one, from deleted sessions too. npm run dev proxies /frame/ like albedo web, with the same headers.

Design rules the client keeps (from the design pass): surfaces separate by a step in tone, not by lines; only what floats (windows, the palette, the view controls, the overview map) casts a shadow; one accent, albedo's mauve by default and editable, used for focus and selection; no status dots; monospace only for code and tool commands.

The client shares the terminal's voice. moods.ts is a port of cli/internal/tui/moods.go (its pools, their order and the seeded FNV-1a pick), so a finished turn signs off with the same kaomoji in both clients: its seed is the turn's last entry time in milliseconds, and its class comes from how long it took. A busy session shows an animated face for what it is doing, read from its phase (preparing, generating, running a tool, compacting, interrupting; only idle is at rest) and the tail of its activity, on one shared 500 ms clock for every face on the page (live.svelte.ts). Every frame of the animation sits invisibly under the current one, so a face keeps its width and nothing beside it moves. burst.ts writes a turn's thinking and tool calls as the terminal's one past-tense line (thought 12s · read tui/{chat,moods}.go · ran go test ×2 +12 −3), from each call's trace, fetched when the line scrolls into view. Speakers are named as in the terminal: "albedo" in mauve, "you" in teal, with "2h later" after a pause of 30 minutes or more. Copy is lowercase, and empty and error states carry a face. The canvas has sparse dots (3 px every 36 px, about 1.8:1 on the ground in both themes) that merge rather than fade as the view zooms out (Dots.svelte): between 100% and 50% every other dot dissolves into its neighbours, leaving the grid as it looked at 100%, and so on a level up, so the dots on screen stay 18 to 36 px apart and 3 px across at any zoom.

Markdown (markdown.ts) also understands maths and diagrams: $…$, \(…\) and $$…$$ / \[…\] (a dollar amount like "$5 or $10" stays prose: the opening $ must touch its text and no digit may follow the closing one) and ```mermaid blocks. They render as placeholders carrying their source, and enhance.ts draws them after the message mounts: KaTeX for maths (trust: false), Pele (pele.run) for diagrams, themed with --pele-* from the canvas tokens, its SVG kept to drawing (no scripts, foreign objects, event attributes or outside links). Both load only when a message needs them.

Notes (nodes/NoteNode.svelte, anchors.ts, notes.ts, quote.ts, Tethers.svelte) are small text boxes on the canvas, saved with the board. Double-click empty canvas or press N for a loose one; the composer's pop-out button moves the draft into a note tied to its chat, for writing at length; selecting text in a window (a message, a step's output, anything of the client's own; not yet a diff, whose text is in Pierre's shadow root) floats a "note" button just under the end of the selection (kept on the canvas, so a window at the edge of the view still shows it), which leaves a note beside the window tied to exactly that text. Dragging a note's grip onto something ties the note to it: the selected text, when the pointer is on it; a window; in a chat, a step (data-entry), or in a reply the block under the pointer (a paragraph, list item, code block, heading, quote or table row), with the speaker's name standing for the whole message; a line in a diff (found by the diff's data-diff key and the row's number inside Pierre's shadow root); or a part or a passage of an artifact's page (asked of the page through its bridge, since it is another document: pageHosts in artifacts.svelte.ts holds each artifact window's bridge, the frame lets the pointer through while a tie is drawn, and the part is kept as a CSS selector, the version and a quote; see robot-docs/artifacts.md); dropping it on empty canvas sets it loose. A passage of text, the client's own or a page's, is kept as the W3C TextQuoteSelector keeps one (quote.ts): its words (up to 2,000 characters) and 32 characters either side, inside the entry it was in, and found again by those after the window redraws, the best match of its context where the words appear more than once; a block is kept the same way. Old saved boards load unchanged. Until a chat's first page is in and scrolled to its end, the thread stays hidden behind a skeleton shaped like the end of a typical chat (measured at phone width: a work line, a message, more work, the reply's last lines and its sign-off), so it never shows its top and then jumps. A chat follows its end as it grows unless the reader has scrolled up (more than 40 px from it); then a pill floats over the composer (on a phone, above the drawer's handle): "see new messages" once the end has changed since they left it (a new last block, or more of a live reply; older pages loading above do not count), "back to latest" until then, and it scrolls back down. Tables in messages scroll sideways instead of squeezing their columns. A review names each file by its last two path segments (…/chat/Chat.svelte), the full path as its title.

While the grip is dragged, what it would land on is outlined (pick in anchors.ts); the drag follows the pointer on the whole document, so a note redrawn mid-drag, or dismissed, never leaves the tie or its outline behind. The target and a quote of it (the diff lines, the passage, or a message's first 600 characters) are kept on the note, so what it was about survives scrolling and closing. A line runs from each tied note to its target, and a target that is a passage (diff lines, a message, a step, selected text, a part of a page) is tinted while on screen: the client's own text under its words with the CSS Custom Highlight API (albedo-note-normal, -lit, -sent), a page's passage line by line over the frame, and a passage that can no longer be found as the block it was in; the note in hand, selected or under the pointer, draws its line and tint strongest. The line is measured in screen space every frame while any note is tied, held inside the window and whatever scrolls it. A note layers like any window: the selected window is on top, so a note just made starts above the rest, and picking a chat or an artifact brings it over the note.

Notes are drafts, as a pending review's comments are: every written, unsent note tied into a session, to its chat or any of its windows (Board.sessionOf), goes with that chat's next message: the chat's own notes first, then each window's in board order, each from the top (drafts in notes.ts). The notes are not drawn in the composer, since each is already tied to its window by a line; the send button widens to a note icon and how many go with the message. A note's footer says what it will do, with next message or on its own, and a click on it switches (alone, saved with the board; boards saved with held read as alone): a note on its own is sent only by its own button. Sending the composer sends what was typed as the lead and every note that goes with it after it, as one message, through the chat's own send (so it shows as pending and can be retried); with notes going, an empty field sends just them. A note also sends alone (⌘↵ or its send button), a review's notes with "send review" (they go with the next message too, and are done once sent), and the palette's "send all notes" sends every session's notes, except those on their own, as one message each. Each note quotes only what it was tied to (a note tied to a chat window itself is just a message; one tied to a part or passage of a page names the artifact, the version and the selector). Sent notes stay on the board, quieter, until dismissed.

On a narrow screen (760 px or less) the client is a plain chat app unless settings ask for the canvas (canvasOnPhone): layout.svelte.ts and Phone.svelte show the session list, then one session's chat filling the screen, with a back button and the browser's back between them (history.pushState). There a review opens inside the chat, under the line that asked for it (views/ReviewView.svelte, the review window's body too), and notes and pop-out are off. The phone's session list and drawer are one picker (SessionPicker.svelte): up to six tiles (pinned, then sessions opened three or more times, most first, then today's), every other session as a list under them (three dots between the two, and every other row takes a softer step of the tiles' tone, so the stripes don't read as tiles), and a search field with a new-session button docked at the bottom on the ground's tone, within thumb reach; while searching it is all list. The phone's chat reads at 16 px unscaled with narrow margins, its composer floats over the thread (which scrolls on under it and ends clear of it), a chat has no bar and takes no gesture from the browser: a handle floating above the composer raises the session picker as a drawer over the chat. The drawer and the composer are one surface: the handle is the sheet's top edge, resting there when closed, and the sheet stands on the composer's bottom edge rather than the page's, so nothing of it reaches under what floats there (Safari's toolbar) and its search lands where the composer was. Dragging it up (or a quick swipe up from the composer itself, unless it would scroll the draft or follows a hold) raises the sheet with the finger while the composer fades into the search bar pinned in its place; dragging it down from its grip, or from anywhere while its list is at the top, lowers it. Let go and it settles open when past half (lowering: while still two thirds up) or flicked up, and otherwise sinks back into the composer; settling tweens the same number a finger moves, so the sheet, the composer's fade and the scrim stay one motion. A tap on the handle opens it, and a tap on its grip or above it, or choosing a session, closes it, and the browser's back still returns to the list, and the composer is one row (picture, field, stop, send).

Pin and archive (Rail.svelte, Sessions.mark): a row in the sidebar shows a pin and an archive button while pointed at or focused. Each is PATCH /sessions/{id}?view=configuration with preferences: {pinned} or {archived} (configureSession, read at its ETag first), shown at once and put back with the daemon's reason if refused. Archived sessions leave the sections and sit under a collapsed "archived" fold (open while filtering), dimmed, with an unarchive button. Pinned rows sort by pin_order and move among themselves, dragged (a line marks where one lands) or with Alt and ↑ ↓ on the focused row: Sessions.movePin patches preferences: {pin_before: <pinned session id> | null} (null is last), shown at once and put back if refused, and the daemon renumbers every pin from 0 in the same commit, bumping each moved session's revision so a stale ETag on it fails with 412. Today and this week are ordered by activity and cannot be moved. The phone's picker offers neither pinning nor moving yet.

search (format.ts) shares the tui’s matching rules and test/fixtures/search/cases.json: exact/prefix matches, identifier initials, substrings, then word-local abbreviations, with stable ties and each query word matching some field. unicode is matched scalar-wise with simple lowercase, accents and combining marks retained; explicitly typed punctuation stays literal. hit offsets refer to the original utf-16 text and never split astral characters; the fixtures use scalar positions. see tui for the cross-client contract.

New session (NewSession.svelte; the + beside the sidebar's filter, the phone list and ⌘K): pick the folder from the folders sessions have used (GET /workspaces, ranked as you type), type a path, or browse to one (GET /workspaces?location=); then the provider profile (GET /settings?group=providers, starting on the default), its model (GET /models?provider_profile=) and effort, starting on the profile's own. Start is PUT /sessions/{uuidv7} with If-None-Match: * and {kind: "new", workspace, provider_profile, model, effort}, and the session opens.

Model picker and context meter (chat/ModelPicker.svelte, sharing ModelChoice.svelte, SearchCard.svelte, and EffortBar.svelte with new session and the command palette, maths in context.ts): one pill shows the session's model, its reasoning effort as individually rounded capsules along the bottom edge (one capsule per effort step the model supports, stepping progressively in --accent lightness and chroma across low, medium, high, xhigh), and a multi-segment ring with the share of the window the context fills ("38%", dimming to --faint once cache_fade expires and turning to the danger colour once the compaction threshold is reached). Hovering the pill opens an interactive context popover (26px storage bar) showing the total window fill (183k / 600k (31%)), a live cache warmth countdown badge (cache warm · 3m 50s vs cache expired from cache_fade), the hatched compaction reserve zone with its threshold label (480k, from GET /sessions/{id}/context?view=summary's trigger_free_percent), and the tokens remaining (297k until compaction); hovering the bar smoothly expands the 4 category segments (system, messages, tool output, images, with a gentle perceptual curve so tiny segments stay easy to target) across 100 % of the bar and shows the hovered category's token count and share underneath. It shows nothing for the context before the session's first usage, and "—" when that usage came from a model other than the session's now. Usage comes from the chat's own session stream (Transcript.usage: the reset snapshot's usage, then usage events), so nothing polls. The window is GET /sessions/{id}/context-window (effective_context_tokens), read once per model the usage names. The cached count follows cache_fade as the terminal footer's does and is redrawn at each step's time. On the canvas the pill sits in the session window's title bar beside the artifacts, agents, background jobs and workspace pills (nodes/SessionNode.svelte), and dragging any of those four chips out of the title bar onto the canvas (Board.undockChip, nodes/ChipNode.svelte) tears it off into a compact tethered window (context with the interactive context meter and model selector pill in its body, or artifacts, agents, jobs with their list inline; sessions.readings shares the chat's live usage and entries with an undocked context window), hiding the chip in the chat's title bar (and routing tethers for open terminals, artifacts, or agents from their row in the undocked chip window via Tethers.svelte, falling back to the chat window when docked) until closing (×) its window docks it back in; on a phone it floats at the top edge of the chat (Chat.svelte) alongside the artifacts and jobs buttons, hiding while the reader scrolls or selects text so the bottom dock stays a clean bare handle above the single-beam composer. Clicking the pill opens a fuzzy-searchable palette of all profiles' models below it (current profile first, matching and highlighting characters across provider names and model ids as in TUI's /model, with the effort segmented bar in the footer; clicking a model row or pressing Enter on the first match switches via PATCH /sessions/{id}?view=configuration with If-Match, or queues the choice while the session is busy and applies it as soon as the turn finishes).

Slash commands (commands.ts): the chat reads the session's commands from GET /sessions/{id}/catalog?kind=commands (only those a human may call; before a runtime has loaded, it uses the catalogue's native_commands, as the terminal does, without preparing extensions or booting a kernel). Each chat loads once, then refreshes when slash autocomplete opens or a slash draft regains focus, so a runtime waking or a capability reload does not leave an old menu stuck in the chat. Concurrent refreshes share a request; closing or switching a chat aborts it. Failed reads show an error and retain the previous menu rather than accepting an empty or partial catalogue; opening autocomplete again retries. Loaded commands are authoritative, and pagination rejects a changed loaded revision. Typing / in the composer lists them, filtered as you type (arrows, Tab or Enter to complete, Escape to clear). A line starting with a command runs it: arguments are read the way the terminal reads them (words in order, the last taking the rest, or a JSON object), and one name that is both a read and a change picks the read when nothing follows it. Skills and command inputs are sent as {kind: "command"} inputs; reads and changes are the daemon operations the catalogue declares, with path, query, headers and body bound from the session record, the arguments or literals, and their answer shows above the composer until dismissed. The answer body owns wheel scrolling at readable canvas zoom, including at its scroll limits; it contains overscroll so neither the transcript nor the canvas moves underneath it.

The (?) between search and settings opens an about pane (About.svelte, under it): a one-line description (a placeholder for now), the main contributors with their Bluesky handles (tiles two to a row, an odd one centred under them), thanks to everyone else with a link to the full history on tangled, and the libraries the client is built on with their licences (pills in centred rows), linking THIRD_PARTY_NOTICES.md.

Settings (the sliders button at the top right, or ⌘,) is a non-modal pane over the board: the theme (follow the system, the default; light; dark), each mode's colours, the canvas dots, and which side the sessions sit on ("sessions on the right" mirrors the shell: the sidebar, its toggle and the brand on the right, search, about and settings on the left, and the about and settings panes open under their buttons; set on <html> as data-sidebar). A mode's colours are three seeds and a contrast, as in the Codex app: accent, background (the ground), foreground (the ink) as hex, with a native picker beside each, and contrast from 0 to 100; an advanced colours foldout adds per-mode error/removal and addition/success colours, plus an optional speaker (you) override (empty means derived from the accent). These apply to failure states, counts and diff backgrounds without changing syntax highlighting, and travel with theme copy/paste and reset. Older saved palettes receive the existing semantic defaults; choosing a season changes only the three seeds, retaining custom semantic colours and contrast. The inline startup script applies the saved semantic colours before first paint, just as prefs does at runtime; accent presets fill the accent for the mode being edited, and a theme copies and pastes as JSON (both modes, or one palette for the mode being edited). app.css derives every other token from the seeds with color-mix in OKLab (surfaces, chrome, fields, secondary text, hover, dots, shadow tint) and relative oklch() (the brand's second stop, and "you" opposite the accent), so any three colours make a whole theme; contrast scales how far each step goes. The colours are madoromi's (MPL-2.0, credited in the pane and in THIRD_PARTY_NOTICES.md): the defaults are its spring, haru, by day (#f6eff8 paper, #584a5c ink, #6928bd wisteria) and by night (#241c26, #e1d1e7, #ddb3ff); its four seasons set both modes at once, keeping each mode's contrast, and the accent presets are its spring inks. Preferences live in this browser (prefs.svelte.ts, albedo.prefs.v4, carrying the theme and dots over from earlier keys) and in an albedo_prefs cookie, which every port on the host shares, so the dev server and each albedo web port open the same way; the cookie wins when both exist, and albedo web drops it before forwarding to the daemon. Settings also take an interface and a code font by name (a comma adds fallbacks; each name is quoted and nothing that could end the value survives, see fontStack), set as --sans and --mono on <html> ahead of the default stacks; where the browser offers queryLocalFonts, the installed families are suggested. Text size is two sliders from 10 to 20 px (type.ts, saved as text): chrome text, and, behind the "canvas text at its own size" switch, canvas text; otherwise the canvas follows the chrome. They become --ui-scale and --canvas-scale on <html> (ratios of the 13 px base, seeded by index.html too), and every font size reads through one: the chrome's as calc(<px> * var(--ui-scale)), a window's from .window in WindowFrame.svelte, whose calc(13px * var(--canvas-scale)) is the em origin for the chat, and its own px sizes through --canvas-scale. A new size uses the same form; text inputs that iOS would zoom on focus keep max(16px, …), and text inside a diagram the script lays out in px (agent cards, the trace's timeline marks) stays at its base size, since that view's own zoom is what enlarges it. The resolved mode is data-theme on <html> and its seeds are inline custom properties there; a small script in index.html applies both before the first paint, and prefs follows system changes while the theme is system.

The chat view (chat/) reads GET /sessions/{id}/history (newest page, then before= as the reader scrolls up, and after= when the stream says something committed) and sends with PUT /sessions/{id}/inputs/{uuidv7}; while the session works, a stop button beside send (or Escape in an empty field) reads the session's run_id and input_order fresh and posts /sessions/{id}/interrupt, so a stop never reaches a later run, {kind: "message", text}; images attach by paste, drop or the composer's picture button (chat/attachments.ts, the terminal client's scheme: each image leaves an [Image #n] marker at the cursor, deleting the marker drops the image, sending renumbers the marked images 1..n in reading order and sends them base64 as images, at most 10); a user entry's images show above its bubble from their history references (/api/sessions/{id}/history/{entry}/image-n). a send stays on screen as pending until its input_id appears in history, and a failed one offers retry with the same id. Your newest message carries a receipt until a reply follows it, drawn as Signal's (chat/Receipt.svelte): a dashed ring while sending, a ring with a check once the daemon has accepted it (sent), two filled ones once its entry is in history (seen), which the daemon commits in the same transaction that takes it into the conversation, so the model has it (a message sent while albedo works stays sent until the loop takes it at its next step). Until it is seen the bubble stays faded. A waiting input is not listed anywhere, so after a reload it shows nothing until it commits. transcript.svelte.ts turns entries into blocks: user bubbles, assistant markdown, and each run of thinking and tool calls between messages folded into one "Worked" line (open while it is the live run or a thought inside it is expanded). Referenced message text is fetched with the page; tool arguments and output only when their run is opened. Markdown is rendered by marked with raw HTML escaped and only http(s)/mailto links (markdown.ts), because transcripts carry model and tool text into a page that can reach the daemon. The session list (sessions.svelte.ts) follows the collection's stream (GET /sessions?scope=roots as server-sent events, followSessions in stream.ts): each activity event patches that session's status and activity in place, so the sidebar, window titles and faces change as the daemon does, and reset, overflow or invalidate reads the list again (once for a burst). While that stream is up the list is also read once a minute in case; while it is down, every 4 s. Both stop while the tab is hidden. A turn's sign-off row carries its actions, trace and copy as icons, shown while the pointer is anywhere over the turn's reply or on the row itself (found from the page when the pointer moves, hover in Chat.svelte), or always on a screen with no hover. What is typed in a composer is a draft kept by session (chat/drafts.ts), outside the composer, which goes with its window when another board is shown, so the draft is there when the board comes back. The text is also written to localStorage (albedo.draft.v1:<session>) once typing pauses for 300 ms, and on pagehide, so a reload keeps it; sending or emptying the field removes it, and one untouched for 30 days is dropped at the next load. The images it marks are not kept (they are too big for storage), so a reload drops them and their [Image #n] markers; switching boards keeps them.

Tool steps and burst clauses read the daemon's derived title from history entries, stream events, or tool progress, falling back to line 1 for older daemons or when no title is provided.

Each chat also follows the session's event stream (stream.ts: GET /sessions/{id}?tail=0 as server-sent events, read with fetch so it can resume with after_generation and after_seq, backing off from 0.5 s to 5 s). Thinking and reply deltas show as they are written, tool progress shows as a running step until its call commits, and status events drive the faces ahead of the session list. A reset carries a snapshot whose active_output and current_progress restore what was in flight. A committed event reads the new history first and then drops the live output it names, so a reply never blinks out between its last delta and its entry. While the stream is down the chat falls back to polling every 1.2 s while busy and 5 s otherwise; while it is up, every 20 s.

Boards live in the browser's localStorage: the list under albedo.boards.v1, and each board's windows (position, size, what they show) and viewport under albedo.board.v2:<id>. Only the active board is mounted; FlowBinder hands it the flow helpers, and anything asked of a hidden board (revealing a new window) runs when it is shown.

Wire types live in src/lib/api.gen.d.ts, generated from docs/openapi.yaml with the exact openapi-typescript dev dependency. Run npm run generate in web/ after changing the contract; npm run generate:check compares the output without writing or downloading dependencies. Version 7.13.0 accepts the canonical OpenAPI 3.2.1 document directly; no projection is needed. SSE item schemas and framing remain handwritten in stream.ts. api.ts, the command catalogue and tool traces use generated aliases; app-only projections (a profile's map key, normalized review changes) stay local. TypeScript 7 (the native compiler) is the tsc of this package (@typescript/native is an alias of typescript@7), and npm run check is svelte-check --tsgo, which type-checks .ts and .svelte with it. The package called typescript is Microsoft's @typescript/typescript6 alias, because svelte-check's source mapping and openapi-typescript import the old JavaScript compiler API that 7.0 does not ship; an overrides entry lets openapi-typescript take it despite its ^5.x peer range. Drop the alias when both tools support 7's API (TypeScript 7.1).

Checks: npm ci, then npm run generate:check, npm run check and npm run build in web/; go -C cli test ./internal/webui/ for the boundary. test.sh runs the offline generation check beside the Go API generation check, so install the web dependencies first. The dev shell supplies Node. The Svelte check and build are not in test.sh.

seeing the client #

A layout or styling change to the client is judged by looking at it, not by npm run check and not by the code. Start a private daemon (its own ALBEDO_HOME and an ALBEDO_DAEMON copy from test/snapshot-daemon.sh, as in AGENTS.md), npm run build in web/, run albedo web --no-open --port 0 with that ALBEDO_HOME (ALBEDO_WEB_DIST points it at another build), open the link it prints with the browser binding, and screenshot the states you changed:

  • a desktop width, the canvas with the panels the change touches open;
  • phone width (760 px or less), which is a different layout (Phone.svelte), unless canvasOnPhone is set;
  • light and dark (settings, theme), since the colours are derived from seeds.

npm run dev works too (it proxies /api from $ALBEDO_HOME/daemon.json). Screenshots the browser binding saves without a path go to $ALBEDO_HOME/cache/browser-screenshots/, not the checkout; page.screenshot() returns the path to read. Close the browser, the server and the daemon when you are done, and check ps for ones naming your home.

Model choices across chip relocation #

A pending model/effort selection belongs to the session, not one picker component. The docked title chip, undocked header, and inline context picker share one in-memory save lane. Dragging or docking a chip keeps its latest unsaved choice, error and in-flight write, and never starts overlapping configuration PATCHes. A failed or unknown write still needs the same deliberate retry; moving a chip does not acknowledge it. Only the mounted picker that owns a completed selection may close its own palette.

With no mounted picker, an already issued write settles but no new write starts. Unsaved state remains available when that session's picker returns. Quiescent, unreferenced lanes are removed. This state is not saved to localStorage or the daemon; reloading the page discards unsaved choices.