small gleam coding and (not yet) persistent agent daemon with a detachable cli forked from okami.mom/albedo
albedo robot-docs artifacts.md
16 kB · 109 lines
Markdown
at main

artifacts #

An artifact is a web page the agent made: one HTML file, a few files, or a whole built app. It opens in a window beside the chat that made it (web), or in the browser from a row in the transcript (terminal), on its latest/ address, which follows updates. Every version is kept, and artifacts outlive their session: deleting a session leaves its artifacts in the gallery, which is the only place an artifact is deleted.

The artifacts extension (src/albedo/harness/extensions/artifacts/, enabled by default) owns the storage, the Python module, the HTTP service and the page bridge. Clients draw it: web/ has the window and the gallery, cli/internal/tui the row, and albedo web (cli/internal/webui) serves the pages.

the model's side #

The Python module artifacts:

  • artifacts.create(title, html=None, *, files=None, directory=None, entry="index.html") makes an artifact at version 1. Give exactly one source: html (the entry file's text), files (a dict of relative path to str or bytes), or directory (a folder to publish whole, such as a build's dist).
  • artifacts.update(id, html=None, *, files=None, directory=None, entry=None, title=None) writes the next version. html replaces the entry file and keeps the rest; files changes only the paths it names (a value of None removes that path); directory replaces the whole tree; with no source it only renames.
  • artifacts.list(limit=20, session=None) lists artifacts, newest change first; session="current" keeps this session's.
  • artifacts.get(id, version=None) describes one version: id, title, version, versions, entry, files (path to size), session, created_at, updated_at.
  • artifacts.read(id, path=None, version=None) returns one file (the entry by default), str for text, bytes otherwise.
  • artifacts.state(id) is what the page saved through localStorage; artifacts.errors(id) is what failed in the page (newest last, at most 20).
  • artifacts.url(id, version=None) is the page's address on the running albedo web, for checking it with the browser plugin. Without a version it is the latest/ address, which follows updates (below).

Each create and update adds an artifact activity to the running cell's trace: {"kind": "artifact", "target": <title>, "artifact": {"id": <id>, "version": <n>}}. That is how both clients learn of it, from history they already read; the trace holds no capability key.

Uploads are content addressed: the module hashes each file (SHA-256), asks the daemon which it lacks (artifacts.missing), sends only those (artifacts.upload, one file per call, base64), then saves the version as a manifest of path, hash and size (artifacts.save). Publishing an app again sends only the files that changed.

Limits: 16 MiB per file, 64 MiB and 2,000 files per version. A path is relative, uses /, has no empty, . or .. segment, does not start with .albedo/, and is at most 512 bytes. Titles are 1 to 200 characters.

storage #

table holds
artifacts id, key (the capability, 32 hex characters), session, workspace, title, version (latest), created_at, updated_at (ms)
artifact_versions artifact, version, entry, manifest (JSON: path to {hash, size}), cell, created_at
artifact_files hash (SHA-256 hex), size, data
artifact_state artifact, state (JSON object of string to string), updated_at
artifact_errors artifact, version, at, message, source, line, column, stack

Nothing is deleted with its session. Deleting an artifact removes its rows and every file no remaining version names.

HTTP #

Under /extensions/artifacts/, all with the daemon token:

  • GET items?limit=&next=&session= — the gallery, newest change first: {items: [Artifact], next}. An Artifact is {id, key, title, version, versions, entry, session_id, session_name, session_deleted, workspace, created_at, updated_at, bytes}.
  • GET items/{id} — one artifact with history: [{version, entry, created_at, cell_id, files: [{path, size}], bytes}], newest first.
  • DELETE items/{id} — 204.
  • GET frame/{id}.{key}/{version}/{path} — one file of one version, with its content type. An HTML file gets the bridge (below) injected first thing in its <head>. A wrong key is 404, as is a missing path; a path ending in / serves its index.html, and an empty one the version's entry. A bare {version} without the slash redirects (302, relative) to {version}/, and an empty path whose entry sits in a folder to that entry, so the page's relative paths resolve inside the version. {version} may be latest: the newest version, whatever it is when asked, so the page's relative files follow too. A numbered version's files are immutable; under latest they are no-cache with the file's hash as ETag (If-None-Match answers 304). HTML is always no-store, since it carries the saved storage.
  • GET frame/{id}.{key}/{version}/.albedo/version?after= — {version}, the artifact's latest, no-store. With after, the daemon holds the answer until the latest passes it or 25 s pass (the waiting request registers in an ETS table, albedo_artifacts:listen, and saving or deleting the artifact wakes it, so nothing polls); without, it answers at once. {version} only has to exist: a following page asks under the version it shows.
  • POST frame/{id}.{key}/{version}/.albedo/state — the page's localStorage, a JSON object of string to string, at most 1 MiB; 204.
  • POST frame/{id}.{key}/{version}/.albedo/errors — one failure, {message, source, line, column, stack}, at most 16 KiB; 204.

The frame routes are what albedo web forwards; a browser never reaches the daemon itself.

albedo web #

/frame/... forwards to the daemon's /extensions/artifacts/frame/... with the token and without the cookie, Origin or Referer. It needs no sign-in: the key in the path is the capability. Only GET and HEAD (files, latest/ and the .albedo/version wait, whose cache headers it passes through untouched, with no timeout of its own), and POST to the two .albedo/ paths, pass. Every answer carries

Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads allow-pointer-lock
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff

so a page opened in its own tab is sandboxed exactly as one in a window.

While it runs, albedo web keeps $ALBEDO_HOME/web.json with its pid, bound port, loopback url (http://127.0.0.1:4317), names, addresses, allow, local, public, and tailnet_https. It removes the file when it stops.

the boundary #

A page has the network: it can load libraries from a CDN, call APIs and open websockets, as the kernel already can. What it must never reach is albedo: the daemon's API, the web client's cookie or page, the browser storage of albedo's origin. Both the window's <iframe sandbox> (without allow-same-origin) and the CSP sandbox header give the page an opaque origin, so:

  • its requests to /api carry Origin: null, which the proxy refuses, and no cookie (they are cross-site, and the cookie is SameSite=Strict);
  • parent.document, document.cookie, localStorage and sessionStorage throw, until the bridge stands in for the last three;
  • it cannot navigate the window that holds it.

Measured in Chrome on 2026-10-21 with a throwaway server shaped like this one: the frame's own document request carries the cookie (it is same-site), every request from inside the page carries neither cookie nor origin, a module script needs the Access-Control-Allow-Origin: * above, and Object.defineProperty(window, "localStorage", ...) replaces the throwing getter.

the bridge #

The daemon injects priv/artifacts/bridge.js into every HTML file it serves, with window.__albedo = {id, version, latest, follow, title, base, state} before it (version is the one served and latest the newest at the time; follow is true when it was served from latest/; base is the served version's numbered frame path ending in /, even under latest/, so what the page saves, reports and sends names the version it showed; state the saved storage). Pages use plain web APIs and, if they want to talk, window.albedo.

  • Storage. localStorage is an in-memory store seeded with the saved state; each change is saved (POST .albedo/state, debounced 400 ms, and on pagehide). sessionStorage and document.cookie are in-memory stand-ins. State is per artifact, so it carries across versions and reloads.
  • Errors. error and unhandledrejection are sent to POST .albedo/errors (at most 20 per load) and posted to the window as albedo/error.
  • Theme. :root gets albedo's colours as CSS variables, from the window when it says (ui/notifications/host-context-changed), else from prefers-color-scheme: --albedo-bg, --albedo-surface, --albedo-text, --albedo-muted, --albedo-faint, --albedo-accent, --albedo-border, --albedo-added, --albedo-danger, --albedo-sans, --albedo-mono, plus color-scheme and data-albedo-theme (light or dark).
  • Messages. albedo.send(text) asks the window to post text to the chat as you, and resolves to {sent: true} or {sent: false, reason} (held by the loop guard, or the chat is gone). In a tab of its own (opened from the terminal, or with the window's new-tab button) there is no window, so the bridge posts {text, gesture} to .albedo/message and albedo web sends it to the artifact's chat with the same header: it reads the artifact with its token, checks the key in the path against it, and keeps the same loop guard in memory per artifact (five a minute without a gesture, thirty in all). There is nothing to hold messages in a tab, so past the guard they are refused, and the reason says to open the page beside the chat.
  • Following. A page served from latest/ in a tab of its own (follow and not embedded) asks GET .albedo/version?after=<version> and asks again each time it answers unchanged; once a newer version exists it saves pending storage (waiting for the save to land, so the next load reads it) and reloads, which serves the newest. A hidden tab stops asking until visibilitychange shows it; a failed request retries after 5 s, and 404 (deleted) stops. A window never follows this way: the web client moves its windows between versions itself. This is the address the terminal's artifact row, the window's new-tab button while it follows, and artifacts.url(id) open.
  • Size. Its document's height is posted as ui/notifications/size-changed, for the phone, which fits an artifact to its content.
  • Notes. A canvas note can be tied to a part of the page or a passage of its text. While a note's tie is drawn over the page, the window posts albedo/point {x, y} (page pixels) and the page answers albedo/pointed with the part under it, as a CSS selector (an #id where one is unique, else tag:nth-of-type(n) steps from body), a quote (its text, alt or value, at most 600 characters) and its rect. The part is what a reader sees there, not the markup around it: a control, else the first element up from the deepest one at the point that is a thing of its own (a picture: img, svg as one, canvas, video and the like; a box that paints a background, border or shadow; a block with words of its own, directly or in inline elements), climbing past wrappers that paint nothing and refusing one that covers most of the viewport. When only those are there, it is the sentence under the pointer, sent as a passage {exact, prefix, suffix} (the words, and 32 characters either side, inside the element the selector names) with the sentence's rect. Text the person selects in the page is reported as albedo/selected {text, prefix, suffix, selector, rects} (the selection's words, its context inside the element holding all of it, and its lines), once they let go and whenever it moves, or {text: ""} when it is gone; the window then offers a note on it. For tied notes the window posts albedo/watch {selectors, passages} (at most 64 each; a passage is {key, selector, exact, prefix, suffix}; sent again when the page loads again), and the page reports albedo/rects {rects: {selector: rect or null}, passages: {key: [line rect] or null}} at once and after every scroll, resize or DOM change, a frame at most, finding each passage again by its words inside its element (or the body), where its context matches best. The page only reports what and where; it learns nothing of albedo or the notes.

The page and the window speak JSON-RPC 2.0 over postMessage, a subset of MCP Apps, so a page written for it mostly works here:

from the page the window
ui/initialize (request) answers {protocolVersion: "2026-01-26", hostInfo: {name: "albedo"}, hostCapabilities: {message: {}}, hostContext: {theme, styles: {variables}, displayMode: "inline"}}
ui/notifications/initialized nothing
ui/message (request; params: {role: "user", content: [{type: "text", text}]}, plus albedo's gesture: whether the person had just clicked or typed in the page) posts the text to the chat, headed From the artifact “<title>” (v<n>):, and answers {}; or answers an error when held
ui/notifications/size-changed sizes the phone's inline view
albedo/error marks the window's title bar and lists the failure under the page
albedo/wheel (a pinch or ctrl-wheel over the page) replays it on the frame, so it zooms the canvas
albedo/pointed, albedo/rects (answers to the window's albedo/point and albedo/watch) outlines the part a tie would land on; draws the line and tint to each tied part or passage
albedo/selected (only while embedded) offers a note on the selected text, beside the window
tools/call and anything else an error: pages cannot reach the agent's tools

The loop guard. A message sent with a gesture always goes; the bridge counts one click or key press for one message, since a timer a click started still runs inside the browser's activation window. Without one (a timer, a render loop), a page may send five a minute; past that the window holds them behind a button that says how many are waiting. It guards against a buggy page starting turn after turn, never against a person using one.

clients #

Web. A new artifact activity in a chat on the board opens a window beside that chat, tied to it like a review; only activities that arrive while the chat is open do this, never ones loaded from history. A window for an artifact that is already open moves to the new version in place. The view pans to it only when its chat is the selected window. A closed window stays closed; the chat's row says it changed and opens it. A window can be pinned to a version, and then it stays there. On a phone the artifact opens inside the chat, as a review does. A page icon in the chat's title bar (on the phone, above the composer; ArtifactsButton.svelte) counts the artifacts the session made and lists them, newest change first, to open one; the list is read when it opens and when the session's work stops. The palette finds artifacts by title; open an artifact… and delete an artifact… list the whole gallery.

Terminal. An artifact activity is the row ↗ open artifact albedo made for you: <title> (… albedo updated: <title> · v<n> after the first version), under its burst whether the burst is open or not. A click opens the page in the browser: the TUI reads the artifact's key from GET items/{id}, finds albedo web through web.json (starting albedo web --no-open when it is not running, on another port if its usual one is taken, and showing the server's own message if it still does not start) and opens the version the row names, <url>/frame/{id}.{key}/{version}/; the first version's row names none, so it opens <url>/frame/{id}.{key}/latest/, which reloads itself as the agent revises the page.