Skip to content

Latest commit

 

History

History
1594 lines (1400 loc) · 91.7 KB

development.md

File metadata and controls

1594 lines (1400 loc) · 91.7 KB

Development

Working on Verkstead, rather than with it: running the loop out of a checkout, and the commands the two halves are built and tested with. A checkout is for working on Verkstead; the released builds, and the four ways to get one, are in the README.

The vocabulary in bold is the project's, and is defined in CONTEXT.md. How it is written on a screen — and every other rule for a string the human reads — is in Copy.

Quickstart

The whole loop, from a fresh checkout. It takes two terminals: verkstead ask blocks until it is answered, which is the entire point of it.

1. Enter the dev shell

$ nix develop

Everything below assumes this shell — it carries the Rust toolchain, sqlite, git, the node and pnpm the viewer is built with, and the Electron the desktop app runs on — and every tool the language descriptors' proofs in crates/server/tests/suite/package_stores.rs install or build with, a JDK, Maven and Gradle among them.

2. Build the viewer and start the server (terminal 1)

$ (cd web && pnpm install && pnpm build)
$ cargo run -p verkstead-cli -- serve --data-dir .
  INFO verkstead_server: verkstead is listening listen=127.0.0.1:8422 peer_listen=0.0.0.0:8423 annex_forward=0.0.0.0:8424 workbench=http://127.0.0.1:8422/?key=… data_dir=. device=86f1933fecb070cbee865fbb84819d14 fingerprint=3F:0A:… home=/home/you sandbox_binds=0 build_cache=Some("/home/you/.cache/verkstead") skills=./skills

workbench= is how you get in. Every page of the workbench and the viewer's own /api/ui/ namespace answer 401 without the Workbench Key, and that link is the address with the key on it: paste it once and the browser holds the cookie from then on. The key is workbench.key in the Data Directory, made at the first start and read back at every one after it.

device= and fingerprint= are what this install is. The id is invented at the first start and read back at every one after it, and it is what every record and URL naming a device will name this one by; the fingerprint is its self-signed certificate's, in the spelling two people compare one in. Both are in the Data Directory beside the key, as device.id and device.pem. The certificate is good for ninety days and the first start with fewer than thirty of them left makes another (ADR 0020) — an expired one is refused at the handshake, so a certificate issued once and read back for ever would be the day every link in a cluster went down together. The id is untouched by that: it is the certificate that is renewed.

While the new certificate is waiting on members to acknowledge it, a line of its own says so and names both fingerprints — the one still going out and the one coming in. On a checkout nothing has been linked to, what that line says is that there was nobody to announce to and the changeover is already over:

  INFO verkstead_server: this device's certificate was near its expiry and has been made again, and there was no member to announce the new fingerprint to fingerprint=9C:4B:…

Once something is linked — which is further down this step — that start announces the new fingerprint to every member instead, over the link it already holds and still presenting the outgoing certificate, that being the only one any of them holds. The changeover ends at the last acknowledgement; a member that was switched off is told by the next call that gets through to it, and one that never answers is a member the human unlinks.

peer_listen= is where another Verkstead reaches this one. A second listener, TLS on every interface at port 8423, presenting the certificate above and asking a caller for one without insisting on it — --peer-listen or VERKSTEAD_PEER_LISTEN moves it, and a second Verkstead on this machine needs its own the way it needs its own --listen. The one route on it anybody at all may read is the identity endpoint —

$ curl -k https://127.0.0.1:8423/api/peer/v1/identity
{"device":"86f1933fecb070cbee865fbb84819d14","fingerprint":"3F:0A:…","name":"workbench",
 "os":"Linux","addresses":["workbench.tailnet-name.ts.net","100.64.0.1","192.168.1.24"]}

-k because the certificate is self-signed and made out to the device id rather than to an address: in a cluster what proves the far end is that fingerprint compared against the one the other machine printed, and there is no certificate authority anywhere in it to check a chain against.

The three after the fingerprint are read off the machine as that request is answered rather than configured anywhere: name is the hostname, os is the platform's own word — a WSL reads Linux (WSL), a Windows machine and the WSL on it sharing a hostname — and addresses is everywhere a peer could reach this device, the tailnet name and address first where Tailscale is up and the LAN behind them. Ask it again from another machine on the same tailnet and it says the same thing; ask it off a laptop that has moved and the addresses have moved with it.

annex_forward= is a third port, 8424 on every interface, and the one nothing answers on until this device keeps an Annex: linking one points it at the Annex's peer listener, and from then on it forwards raw TCP there, the TLS inside it the Annex's own (ADR-0024). Before that a connection to it is closed as soon as it is accepted. --annex-forward-listen or VERKSTEAD_ANNEX_FORWARD_LISTEN moves it, and a second Verkstead on this machine needs its own here too.

And the same device says so on the LAN, so that nobody has to type any of that. A start advertises _verkstead._tcp.local over mDNS — in this process, with no avahi or Bonjour to install — carrying the device id, the hostname, the OS word and the port the peer listener really landed on. Anything that browses mDNS reads it back:

$ avahi-browse -rt _verkstead._tcp
= enp10s0 IPv4 86f1933fecb070cbee865fbb84819d14  _verkstead._tcp  local
  hostname = [86f1933fecb070cbee865fbb84819d14.local]
  address = [192.168.1.24]
  port = [8423]
  txt = ["port=8423" "os=Linux" "name=workbench" "id=86f1933fecb070cbee865fbb84819d14"]

The instance is named by the device id rather than by the hostname, because two Verksteads on one machine are two devices and a hostname cannot tell them apart — start the second one below and this lists two, each naming its own peer port. --no-advertising or VERKSTEAD_NO_ADVERTISING=1 turns it off, and on NixOS services.verkstead.advertising = false; does: what goes out is a hostname, an operating system and a device id, on a LAN that may not be yours. A server asked to stop — a SIGTERM, or a ^C — withdraws the advertisement on its way out, which is the one ordered stop this server has; a killed one leaves the row on the other machine to run out on its own TTL, the way a shut lid does.

And the other half of it is what the pane draws under those rows. The same service browsed rather than advertised, which is the Discovered list: every device this one has found and is not already in a cluster with, each with every address it was found at and an Add on the row.

$ curl http://127.0.0.1:8422/api/ui/devices/discovered
[{"device":"0011223344556677889900aabbccddee","name":"kitchen-mini","os":"macOS",
  "addresses":["192.168.1.31:8423","100.64.0.9:8423"],"found":["Lan","Tailscale"]}]

The browse runs while that list is being read and not otherwise. It starts on the first read of it and stops once nothing has read it for five minutes — a phone that closes a tab says nothing, so the reading being read is the whole of what governs it. Which means the first read is empty or short however many machines are out there: a browse is cold when it starts, and the rows arrive over the seconds after it, each as a discovered nudge that an open pane redraws on.

The rows are the Nudge's and nothing polls for them. The one interval in the viewer is on this read alone, once a minute while the pane is open, and what it is for is the spell above rather than the rows: a browse that has heard nothing new announces nothing, so without it the server would stop browsing five minutes into a pane somebody was still watching, and a second Verkstead started after that would never be heard. A test reads the list again itself, which renews the spell the same way.

And found is a list because there are two ways of being found. A tailnet carries no multicast, so there is nothing to hear on one: the tailnet half asks instead, reading the online peers out of tailscale status --json and putting the identity endpoint's question to each of them on port 8423 as the list is read. So the tailnet rows are in the first answer where the LAN rows arrive after it, and a machine on this network and this tailnet is one row that says Lan and Tailscale both, its LAN address first. Bounded, because how many nodes a tailnet has is nobody here's decision: sixty-four peers at most, sixteen at a time, three seconds apiece. RUST_LOG=verkstead_server::discovery=debug says which peers were asked and what each of them answered — a phone or a server with nothing on that port is a debug line and no row.

Three kinds of device are left out of the merged list — a member, this device, and one a join is already pending for — so what the list holds is only what there is anything to press. Start the second Verkstead below with a data directory of its own and this lists it; link the two and it is a member above instead.

Three routes stand outside the member gate and they are the whole of the un-gated surface: that identity endpoint, the join post, and the cancel and the dial-back a join is settled through. Every other path on that port answers 403 and says so — you are not a member of this verkstead's cluster — whatever you present and whether or not a route answers it, so a stranger is told it is a membership they are missing rather than a path that is not there. Behind the gate is one membership said three ways: a device put on this one's list, a device taken off it, and the certificate one of them stands under changed.

Seeing a link made takes a second Verkstead, which on one machine means a second of everything: its own Data Directory, its own workbench port, its own peer port and its own forward port. In a terminal of its own —

$ mkdir -p /tmp/other
$ cargo run -p verkstead-cli -- serve --data-dir /tmp/other \
    --listen 127.0.0.1:8522 --peer-listen 0.0.0.0:8523 \
    --annex-forward-listen 0.0.0.0:8524

Two installs, two device ids, two certificates. Open the Remote access pane on the first one's workbench: its Devices section holds one row, marked this device, and under Discovered the second install appears within a second or two of the pane being opened — heard over the multicast, drawn with the port its listener bound, and with an Add on the row. It reads LAN and not Tailscale however much Tailscale is on this machine: the tailnet half asks this machine's peers, and the second install is on this machine. Two machines on one tailnet are the case that reads Tailscale, and LAN and Tailscale where they share a network too.

Press that Add and nothing is typed anywhere. The press names the device rather than one of its addresses, a discovery having found a list of them: the server dials every address on the row in the order it found them — the LAN's first, that being the shorter road — and posts the join at the first that answers. The box under the list is what is left for the devices neither half reaches, and it takes the one address it always did: 127.0.0.1:8523 for the second install, the port being needed only because both are on this machine.

What happens then is the whole of the stage, and the two presses are one act from here on. The first device dials that address, takes whatever certificate it presents for the one call, and posts what it is; the second writes the question down, holds it ten minutes, and raises a modal in every workbench it has open with a push to any phone subscribed to it. The first draws a pending row, Waiting for confirmation on …, with its own fingerprint under it — the same string the modal over there is drawing, for two people at two screens to compare by eye — and a Cancel.

Press Allow on the second one's modal and nothing else is pressed anywhere. The second dials the first back, checks the certificate it meets is the one the request pinned, and hands over itself and every member it holds; the first checks that certificate against the one it met when it asked. Both lists now read the same, and a third Verkstead joining through either of them lands on all three.

The pending row and the discovered row are never both drawn: a device a join is pending for is one the Discovered list leaves out, so the press moves a row from under the list to above it. And a row that went stale between being drawn and being pressed — the machine switched off in between, so nothing answers at any of the addresses it was found at — is refused naming the device and dropped from the list, rather than sitting there refusing again. A far end that answered and said no keeps its row, being exactly where the row said it was: only a press that reached nobody says the row was wrong.

Through the API rather than the pane, which is what a test does — the discovered press first, then the typed one:

$ curl -X POST http://127.0.0.1:8422/api/ui/devices/discovered/0011…ee/add
$ curl -X POST -H 'Content-Type: application/json' \
    -d '{"address":"127.0.0.1:8523"}' http://127.0.0.1:8422/api/ui/devices/joins
$ curl http://127.0.0.1:8522/api/ui/devices/asking
[{"request":"5b1f…","identity":{"device":"86f1933f…","fingerprint":"3F:0A:…",
  "name":"workbench","os":"Linux","addresses":["192.168.1.24"]}}]
$ curl -X POST http://127.0.0.1:8522/api/ui/devices/asking/5b1f…/allow
$ curl http://127.0.0.1:8422/api/ui/devices

Unlink is the row's other press, and it takes that device out of the cluster for everybody rather than cutting this device's own half of a link: every member drops it and the device itself is told to forget the rest. It is asked once, over the page, as Remove on a Repo is — and it works on a member that is not answering, which is most of what it is for. A member the last dial found nothing at stays on the list, dimmed, reading unreachable.

A device asking to link is the one thing a stranger writes into this machine, so it is bounded at both ends: a post saying more about itself than is kept is refused, and so is one that would take this device past sixteen questions held at once.

Once two devices are linked, either one's whole workbench is reachable through the other. A member serves /api/ui/ over its peer listener behind the member gate, and the device the browser opened relays for the rest: everything under /api/ui/members/{device}/… is put to that device verbatim — method, path, query, body and the headers that matter — and its answer comes back untouched, status and body and all. The prefix takes the place of /api/ui, so /api/ui/members/0011…ee/conversations/4 is that device's own /api/ui/conversations/4 and nothing else. The browser stays same-origin throughout and a device's workbench key never leaves it: what admits the hop at the far end is this device's certificate, and the cookie is not passed on.

$ curl http://127.0.0.1:8422/api/ui/members/0011…ee/conversations
$ curl -X POST -H 'Content-Type: application/octet-stream' --data-binary @notes.md \
    http://127.0.0.1:8422/api/ui/members/0011…ee/conversations/4/attachments/notes.md

The body is streamed rather than held, in both directions, so an attachment is an ordinary post here and the limit that refuses an oversized one is the far end's own 413 rather than a judgement made after buffering the file. Three Device Ids are refused by name instead of dialled: one that is no member's, this device's own — local URLs keep their shape, so nothing should ask — and a member that answered at none of the addresses it advertised, which is the row the pane is already drawing dimmed.

And the news comes back the same way. This device holds one Nudge stream to each of its members — that member's own /api/ui/nudges, read over the peer listener — and announces everything down it on the stream its own pages are listening to, under the Device Id it came from. So a page drawn on a member's Conversation stays fresh without a poll and without a reload, and a phone on the tailnet hears about that machine at all. The streams are the server's rather than the browser's: one per member serves every page this device has open.

$ curl -N http://127.0.0.1:8422/api/ui/nudges
event: nudge
data: {"kind":"set","conversation":4}

event: nudge
data: {"kind":"set","conversation":7,"device":"0011…ee"}

event: nudge
data: {"kind":"everything","device":"0011…ee"}

A Nudge with no device is this device's own and is the frame it always was; one with a device is that member's news, and the viewer's own table keys the reads it makes by it. The everything kind is the stream itself rather than anything in the world: a member's stream that has just been taken up knows nothing about what it missed, so it says read back whatever of this device is on screen. Nothing at all is announced for a member that is not answering: the page keeps what it last read and goes stale, exactly as it does when its own stream is down. A stream that ends is taken up again five seconds later, and while nothing answers at all that wait doubles to a minute — a laptop that is shut for a fortnight is worth a dial a minute rather than one every five seconds. What goes over the peer listener is this device's own news alone: in a cluster everybody holds a stream to everybody, so a device passing on what a third one told it would be saying that news was its own.

That is the whole of it — there is no boundary flag to say. A repo is registered from anywhere the server can read, an Agent Profile names an account anywhere the server can read, and every path field browses the same way, opening on the server's own HOME when nothing has been typed into it. What keeps a session to its own Conversation is the Sandbox, composed from the Repo and the Profile that Conversation names.

The one path list left is the Sandbox Configuration binds, and they are said in two places — --sandbox-bind DIR, and the same grammar on the settings page's Sandbox binds section. What a session gets is the union; see the "paths" payload further down this section. The flag is the shape a service unit wants, where startup is the moment to hear about a typo and a bind that is not there refuses to start; the settings are the shape a bare binary wants, where a save has to land whatever it was told and an entry that will not resolve is reported rather than fatal.

Everything Verkstead makes goes in one place, the Data Directory: the database at verkstead.db, the worktrees, the installed skills, the handoff directories, the files this device is, and the settings files. --data-dir says where, or VERKSTEAD_DATA_DIR. Said nothing, it is the platform's own place for it — ~/.local/share/verkstead on Linux, ~/Library/Application Support/Verkstead on macOS — which is what an installed Verkstead wants and not what a dev run out of a checkout does: --data-dir . is why every command here says it, and it keeps the database, the worktrees and the settings beside the checkout where they can be deleted with it.

The desktop app is the Electron project in desktop/, started with pnpm start from this shell (ADR 0020, which supersedes ADR 0012). It finds the headless verkstead the workspace built, brings it up beside itself as serve --desktop, waits for the server to answer on /api/v1/health and opens one window on the workbench already logged in — the Workbench Key read out of the Data Directory rather than pasted. So it wants the two commands above run first: the viewer built, because the server serves it, and the CLI compiled, because that binary is what the app starts. Nothing at target/debug/verkstead is a dialog naming the path it looked at, and VERKSTEAD_CLI names a binary somewhere else — a release build, or the one a Release shipped.

$ export VERKSTEAD_DATA_DIR=$PWD   # the checkout, which is what --data-dir . says above
$ (cd desktop && pnpm install && pnpm start)

The Data Directory is said through the environment here, because the app has no flag of its own to say it with: the sidecar inherits the shell the app was started from, so VERKSTEAD_DATA_DIR is to the app what --data-dir . is to every other command in this document, and a launch that says nothing gets the platform's own place again. Every setting but one is the server's the same way — the exception is the address, which the app hands the sidecar as --listen 127.0.0.1:8422 rather than inheriting: the app probes that address, waits on it and loads it, so a VERKSTEAD_LISTEN you have exported for a verkstead serve of your own is overridden and the log says it was. An address something is already listening on — the serve above, say — is a dialog and a nonzero exit rather than a second Verkstead beside the first, and a second pnpm start is the first window brought forward rather than either of those.

pnpm lint, pnpm typecheck and pnpm test in that same directory are the three things CI runs over it. The lint is one rule and it is the wall around Electron: everything the app decides is a function of values the entry file hands it, so that vitest can call it without an application under it. Which is what the tests are over — the main process's pure parts, there being no driven end-to-end suite (ADR 0020).

What it puts on the screen is one window and one icon in the tray. The window loads the workbench off 127.0.0.1:8422 with nothing about the viewer changed to draw inside it; a link that leads off the workbench opens in the browser you already have rather than navigating the window away from it; and where the window was last time is where it comes back, remembered in the app's own user data because where a window sits is a fact about the desk rather than about your Verkstead. The menu bar is hidden on Linux and Windows with copy, paste, zoom, reload and the developer tools still on their keystrokes — Alt brings the bar down — and on a Mac the strip at the top of the screen carries Verkstead's own menus: Settings… on ⌘,, a File menu, Go to move between the open Conversation's panes on ⌘1–⌘5, and Help (ADR 0020, Amended: the Mac menu bar). The icon's menu is Open, View Logs and Quit, a left click on it opens the window, and its Quit never asks. Closing the window is a choice rather than a quit — keep running in the tray, which is the default, ask before quitting, or quit — while the server ending is still the app quitting. The sidecar's stdout and the app's own lines both go to verkstead.log under the Log Directory, which the app names on the terminal as it opens it and which both View Logs open.

And a held middle button scrolls the pane under it, on Linux as on Windows. Chromium has middle-click autoscroll already and enables it on Windows alone, so the app asks Blink for it by name — --enable-blink-features=MiddleClickAutoscroll, appended before there is a renderer to read it. Asked for on every platform rather than on the one that needed it: a gesture is the same gesture wherever there is a middle button to hold, so it is one switch and no platform branch — Windows is told what is already true there, and a Mac with a three-button mouse plugged into it gets the same gesture as everywhere else. It takes nothing away from the other thing a middle button does on Linux, a middle click in a text field still pasting the X primary selection, and a middle press on a link still scrolling nothing. autoscroll.ts is the switch and the whole of it.

And the window has no title bar of its own. What stands at its top-right corner on Linux and Windows is the platform's own controls overlay, drawn on the paper the page's heads are drawn on with its symbols in their ink and as tall as the head beneath it: the page pushes what it is drawn in, how tall its band stands and where the row inside that band is over the same preload bridge at load and at every flip of the machine's colour scheme, so going dark recolours the controls with no restart and the log says each push. A Mac has traffic lights rather than an overlay, so it ignores the two colours and the same push moves the lights instead — centred in the head's first row, left of the wordmark, which is how a larger text size carries them down the window along with the head. What moves the window is any pane head — the sidebar's wordmark, a pane's own title row, the settings, the composer — with every button standing in one still pressing, and a double-click on one maximising. The frame pads whichever head is at an edge of the window clear of the controls, so nothing in a head ends up underneath them; and a page with no head at all — the setup wizard, the no-such-page, the moment before the onboarding verdict lands — draws a bare bar of the same height to be moved by. In a browser on this machine there is none of it: the drag regions are inert, the insets are nought, and there is no bridge for the colours to cross. CONTEXT.md's Window Decorations is the account of the whole of it.

On a Wayland session the overlay can be a close button alone, and that is Chromium rather than your desktop. The pinned Electron picks Wayland by itself where the session says it is Wayland, and on a nested COSMIC 1.2.0 the overlay came out 32 px wide with nothing in it but close; the same app on the same session over Xwayland is 96 px and the usual three. COSMIC is refusing nothing — its toplevel carries all four window capabilities, and neither its own toolkit settings nor a GTK decoration layout moves it. So maximise is a double-click on any head, and minimise is the app's own to ask for: nothing COSMIC ships binds a key to one. The whole measurement is in stage 05's brief, and what a packaged Linux app does about it is that stage's to settle.

The Desktop section at the top of the settings page is where those choices are made, and it is drawn only inside the app: the page reaches the app over a preload bridge on the app's own window, so the same settings page in a browser on this machine — or on a phone over the tailnet — has no such section, and nothing about any of it is on the wire or in config.yaml. It holds When the window is closed, Show tray icon, Launch on Startup and a View Logs button opening the file the tray item opens. What a dev run writes it to is desktop.json, beside the window.json the window's place is kept in, under Electron's own user data rather than anywhere --data-dir says: deleting it is the app back at its defaults. Turning the tray off takes the icon away that moment and greys the keep-running position, the choice falling to Quit while there is no icon to come back from. And Launch on Startup is greyed on a run from a checkout, with a note saying it needs an installed Verkstead: what an autostart entry could name here is the dev shell's Electron in the nix store plus this build directory, which breaks the next time either moves — so the whole path is written and under vitest, and the writing is what is refused. Its account is CONTEXT.md's Desktop Settings and Startup Registration.

And a packed AppImage run from outside ~/Applications offers to install itself before its sidecar starts — the Install Offer, never on a checkout run or a --hidden start. --install is its Install with nobody asked, which is how to try the move without a dialog, and how the release's Linux and Mac legs install the file they upload before running it (Releasing); Don't ask again is installOffer: false in that same desktop.json, and deleting the key asks again. There is no /dev/fuse in a session here, so the packed file is run with APPIMAGE_EXTRACT_AND_RUN=1, which sets $APPIMAGE and $APPDIR the way the mount does.

And every notice the phones get is a notification on this desk, drawn by the app rather than by Web Push, which Electron cannot do: it ships without Google's push client, so a subscribe inside the window is refused however many checks pass on the loopback (ADR-0020, Amended: notifications are the app's own). The app holds GET /api/ui/push/notices open on its sidecar for the whole run — a stream of the same JSON a phone's service worker is handed — and the log says the notices stream is held each time it opens. A notice is shown only while the window is not in front: hidden to the tray, minimised, behind another app, or never shown by a --hidden start. A window in front already draws the news, so a notice that arrives then is logged as withheld and nothing else. A click on one opens what it is about, which is the page a tap on the phone opens. The switch is the notifications switch on the Settings head, which inside the app is the app's own notifications in desktop.json, on unless it is turned off.

To see one from a checkout, start the app, make a Conversation the way step 3 of the quickstart does, close the window to the tray, and ask a Set deferred so the command returns. Every line about a notice is in the log either way: shown, withheld and why, or failed.

$ verkstead ask --deferred --server http://127.0.0.1:8422/conversations/1 examples/questions.yaml

A headless run needs a notification daemon on its session bus. On Linux Electron draws through libnotify, which it loads at runtime, and libnotify hands the notification to whatever owns org.freedesktop.Notifications. Under Xvfb there is nothing, so the app says it showed one that nobody drew. Give the run a bus of its own and a daemon on it, and ask the daemon afterwards — this is what the AppImage's release leg does:

$ eval "$(dbus-launch --sh-syntax)"
$ dunst &
$ (cd desktop && pnpm start -- --hidden) &
$ dunstctl history    # after the ask: the Set's title, if it arrived

A desktop session has its own daemon already, so nothing above is needed there. On a Mac the notification centre is the daemon, and it finds an app through LaunchServices. A packed Verkstead.app run straight out of the mounted dmg is refused with Notifications are not allowed for this application, and the same bundle copied into /Applications is presented, ad-hoc signature and all. log show --predicate 'process == "usernoted"' says which of the two happened. On Windows the daemon is the Action Center, under the AppUserModelID the Start-menu entry carries. A run from a checkout has no such entry, so a toast from pnpm start there may not appear where the installed app's would.

One directory is made outside it: the Build Cache, at $XDG_CACHE_HOME/verkstead — ~/.cache/verkstead on most machines — unless --build-cache-dir says otherwise. Every session gets it writable, with CARGO_HOME inside it, so a crate is downloaded once for the machine rather than once per Conversation; with sccache on the PATH the server was started from, every session is told to compile through it as RUSTC_WRAPPER — and a CMake build as its compiler launcher — and the compiling is cached too, on all three platforms. The dev shell carries one, so a checkout run gets the whole thing. It is on with nothing configured, and the settings page is where a language is switched off, or its compiled store given a size.

None of those variable names are Rust's by name in the server. A language is a descriptor — data, in one grammar — and the seven Verkstead ships are crates/server/languages.yaml, embedded in the binary and read by crates/server/src/languages.rs, which is the module's documentation as well as its data: Rust and C/C++, which compile, the four package stores — Go, Node, Python and .NET — whose eleven tools download into this directory the same way, and the JVM, whose jvm entry moves Maven's local repository and Gradle's whole home into it and runs Gradle with no daemon in a session. What a session is given is whatever the loaded descriptors say, with the languages: map in config.yaml merged over the built-ins key by key. {cache} in one of them is this directory; {stores} is a second one beside the worktrees, for a store that has to share a filesystem with the project — pnpm's, deno's, bun's and uv's, which is what makes it a directory a checkout run really has — and it is made only where a loaded descriptor names it — at startup and again as each session is spawned, so a descriptor hand-edited in between the two is one the next session really starts with. A key the grammar does not have is refused like a refused variable, so a misspelled one falls back to the built-in with a reason rather than silently setting nothing. The grammar is ADR-0021's, and the built-ins in crates/server/languages.yaml are its worked examples.

The sccache server is Verkstead's own, not the sessions'. It comes up as a child of the running server the first time a session starts while a language naming the sccache capability is switched on — Rust's and C/C++'s both name it, the second setting CMake's two compiler-launcher variables and not CC or CXX — in a sandbox holding <data-dir>/worktrees and the build cache and nothing else — so ps shows one more sandboxed child beside each session's, and it goes when the server does. The switch rather than the checkout: a repo whose Cargo.toml is not at its root is handed RUSTC_WRAPPER all the same, and a client with no server of Verkstead's to reach starts one inside its own sandbox, which is the whole hazard. What a descriptor's detect manifests are still read for is the composer's warning. That sandbox is described and rendered by the code a session's is, so it is bwrap on Linux, sandbox-exec on a Mac and the session account on Windows without any half saying which. Every session's sccache is only the client half reaching it. Sessions starting their own is what this replaces: they all bind one port, and the loser's compiles then run in the winner's sandbox where its worktree is not reachable. It is started with SCCACHE_BASEDIRS naming every worktree, so a C/C++ compile hashed with its absolute paths hits across Conversations; sccache reads that once, so a new worktree restarts it only while no session holds it. What C/C++ covers and what it does not — generators, /Zi, a build directory outside the worktree — is the design doc's, beside ADR-0021.

Windows had neither half for a while, and the reason has gone. A session there ran inside an AppContainer, which is refused every connection to the local machine — the probe behind ADR 0014 timed out on 127.0.0.1 and on the machine's own LAN address alike, and the sccache client it ran panicked reading its own configuration before it got that far. A session runs as a local account of Verkstead's own now: an ordinary local account reaches the loopback like anything else, and the profile it is handed is a real one with both halves made, which is what that client was looking for. So the switch is on there like everywhere else, and the compile server on that platform runs as the same account a session does, behind entries of its own — a compile server started as the human would be every dependency's proc macro running with the database and the settings files in reach.

And a Windows session finds the machine's Rust through rustup. There is no /nix on that platform for a toolchain to be on, so what a session runs is the human's own: ~/.cargo/bin is on its PATH already, being under the home, and the rustup home those shims resolve their toolchain out of is granted read-only beside it with RUSTUP_HOME naming it — a sandbox has a profile of its own, so rustup left to resolve against that one answers could not choose a version of rustc to run. RUSTUP_HOME where the machine sets one, ~/.rustup where it does not, and nothing at all where there is no rustup on the PATH, which is every nix machine.

A Mac whose Rust is rustup's finds it the same way, from the Dock too. rustup's installer puts ~/.cargo/bin on a PATH by writing a line into a shell profile, which launchd never reads, so the Mac floor carries ~/.cargo/bin itself, behind ~/.local/bin and ahead of Homebrew's prefixes — and with rustup found there, the rustup home is granted as it is on Windows.

A session's GitHub auth and the author of its commits are two of those settings rather than anything found in a home directory. Who you are is an Author, keyed by a uuid and kept in the database as a Replicated record (ADR-0028) — added, edited and removed on the settings page rather than in a file — and its token is in secrets.yaml beside the database under that uuid:

# secrets.yaml
author_tokens:
  3f2c9a4e-8d1b-4c7a-9e2f-5b6d7c8e9f01: ghp_...
# config.yaml
languages:
  rust:
    enabled: true
    size: 30G
  go:
    enabled: true
  node:
    enabled: true
  python:
    enabled: true
  dotnet:
    enabled: false
  gleam:
    label: Gleam
    detect:
      - gleam.toml
    env:
      HEX_HOME: "{cache}/hex"
cleanup:
  trim:
    enabled: true
    days: 3
  delete:
    enabled: false
    days: 30
conflict_resolution: merge
share_on_done: false
sandbox_binds:
  - /var/cache/verkstead-node
  - /var/cache/verkstead-cargo

languages is one entry per language, merged key by key over the descriptors embedded in the binary — the seven Verkstead ships are Rust, C/C++, the four package stores and the JVM, and a save from the settings page writes an entry for each: enabled and size are the two the page writes, and everything else in an entry is the installer's. dotnet: enabled: false above is a machine that builds no .NET and would rather not have NuGet's three variables in every session. gleam is a language Verkstead has never heard of and works all the same. rust_build_cache is where Rust's two used to be said, and is still read as Rust's — the map wins where both say something, and the first save from the settings page carries it into the map and leaves the old key out of the file.

A Data Directory from before the list said git_author: in config.yaml and github_token: in secrets.yaml. The first start moves the two into the list as one Author under a fresh uuid, and neither key is in either file afterwards (ADR-0025). One from before the records said its Authors as authors: in config.yaml: the first start takes them up into the database under the uuids they carried, so every Repo still resolves its Author, and takes the key out of the file. An authors: key written back in by hand afterwards is ignored, and the log says so.

In a cluster every device holds every Author: each serves its whole table of them on GET /api/peer/v1/records/authors behind the Member Gate, and pulls and merges each member's at the start, on a member's Authors Nudge, when a member's Nudge stream is taken up again and when the membership moves. The Author mirrors an earlier release wrote down become records of the same uuid stamped older than anything, which the real record replaces on the first pull.

sandbox_binds at the foot is the other place the Sandbox Configuration binds are said. A bind is one absolute path, every session gets every one of them, and what the server goes by is the union of this file and the installation's own flags.

Every session started after that gets the token as GH_TOKEN, which gh honours without being told to — as does the server's own gh, the one that reads a pull request's checks, commits and comments onto a Timeline — and gets git configured through the environment — the author, gh auth git-credential as the credential helper for GitHub, and SSH GitHub remotes rewritten to HTTPS so a git push inside authenticates with the token. There is no file to write and nothing to log in to inside a sandbox. With no token — no file, an empty one, one that will not parse — sessions start anyway and gh inside says it is not logged in, and the server's own gh falls back to whatever login the host has; with no author, git inside asks to be told who you are.

Either file can be written through the viewer's API instead of by hand, which is what the settings page saves through:

$ curl http://127.0.0.1:8422/api/ui/settings
{"git_author":{"id":null,"display_name":"","name":"","email":""},"github_token":null,
 "languages":[{"name":"rust","label":"Rust","enabled":true,"size":"30G",
   "size_configured":false,"compiling":"Cached","unread":null},
  {"name":"go","label":"Go","enabled":true,"size":"30G",
   "size_configured":false,"compiling":null,"unread":null},
  {"name":"node","label":"Node","enabled":true,"size":"30G",
   "size_configured":false,"compiling":null,"unread":null},
  {"name":"python","label":"Python","enabled":true,"size":"30G",
   "size_configured":false,"compiling":null,"unread":null},
  {"name":"dotnet","label":".NET","enabled":true,"size":"30G",
   "size_configured":false,"compiling":null,"unread":null}],
 "cleanup":{"trim":{"enabled":true,"days":3,"days_configured":false},
   "delete":{"enabled":false,"days":30,"days_configured":false}},
 "conflict_resolution":"Merge",
 "share_on_done":false,"paths":{"binds":[]},
 "ignored_comments":[],"instructions":""}
$ curl -X POST -H 'Content-Type: application/json' \
    -d '{"git_author":{"id":null,"display_name":"",
           "name":"Tobias Cohen","email":"tobi@tobico.net"},
         "github_token":{"Set":{"token":"ghp_..."}},
         "languages":[{"name":"rust","enabled":true,"size":""},
           {"name":"go","enabled":true,"size":""},
           {"name":"node","enabled":true,"size":""},
           {"name":"python","enabled":true,"size":""},
           {"name":"dotnet","enabled":false,"size":""}],
         "cleanup":{"trim":{"enabled":true,"days":""},
           "delete":{"enabled":false,"days":""}},
         "conflict_resolution":"Merge",
         "share_on_done":false,
         "sandbox_binds":["/var/cache/verkstead-node"],
         "ignored_comments":"Keep","instructions":"Keep"}' \
    http://127.0.0.1:8422/api/ui/settings
{"settings":{"git_author":{"id":"3f2c9a4e-8d1b-4c7a-9e2f-5b6d7c8e9f01",
    "display_name":"","name":"Tobias Cohen","email":"tobi@tobico.net"},
  "github_token":{"last_four":"cdef","at":"2026-08-23T08:23:15.041950412Z"},
  "languages":[{"name":"rust","label":"Rust","enabled":true,"size":"30G",
    "size_configured":false,"compiling":"Cached","unread":null},
   {"name":"go","label":"Go","enabled":true,"size":"30G",
    "size_configured":false,"compiling":null,"unread":null},
   {"name":"node","label":"Node","enabled":true,"size":"30G",
    "size_configured":false,"compiling":null,"unread":null},
   {"name":"python","label":"Python","enabled":true,"size":"30G",
    "size_configured":false,"compiling":null,"unread":null},
   {"name":"dotnet","label":".NET","enabled":false,"size":"30G",
    "size_configured":false,"compiling":null,"unread":null}],
  "cleanup":{"trim":{"enabled":true,"days":3,"days_configured":false},
    "delete":{"enabled":false,"days":30,"days_configured":false}},
  "conflict_resolution":"Merge",
  "share_on_done":false,
  "paths":{"binds":[{"path":"/var/cache/verkstead-node","repo":null,
    "source":"Settings","resolution":{"Unresolved":{"why":
      "the server cannot see it: there is nothing at that path"}}}]},
  "ignored_comments":[],"instructions":""},
 "verified":{"Account":{"login":"tobico","missing":["gist"]}}}

The token goes one way. What comes back about it is its last four characters and when secrets.yaml was written, never the token itself. Saving one asks GitHub who it authenticates as and answers with the account or with what went wrong — and writes it down either way, because a token is pasted once out of a page that will not show it again, and a network that was briefly down is no reason to send somebody back for another. "missing" beside the account is the scopes Verkstead needs that GitHub says the token has not been given — gist, which publishing a share writes with, and empty on a token that carries it or on a fine-grained one GitHub named no scopes for at all. "github_token" is "Keep" to leave the configured one alone, which is what a save of the author fields sends, and "Clear" to take it away. Both halves are about the Author whose uuid "git_author" carries; a null uuid is the first Author, made under a fresh uuid where there is none yet and the save says anything about it.

"languages" is one entry per descriptor the server loaded — the seven Verkstead ships and whatever config.yaml added — sent as values rather than as an action, and the whole list every time, because a save writes the whole file. It carries the two keys the page draws, the switch and the size, where an empty "size" is no size configured and puts the default back — and nothing else, so the rest of an entry in config.yaml is left exactly as its author wrote it. Everything else in what comes back is read-only: "label" is the descriptor's, "size_configured" says whether the size is one somebody typed or the default being shown, "compiling" is null on a language naming no sccache capability — which is what says nothing reads its size and the pane draws no field for it — and on one that does it is the server's own environment, "Cached" where it found an sccache and "NoSccache" where it did not. "unread" is null unless that language's entry in the file would not load, when it carries the reason and whether the language fell back to "BuiltIn" or to "Nothing".

"sandbox_binds" is the list config.yaml holds, sent as values in the grammar the flags use — so a Verkstead started with no flags at all gets its first bind through this endpoint. What is sent is what the file holds afterwards, and the "paths" that comes back is both sources at once: every entry says whether the installation's flags or the settings said it, and whether the server can see what it names right now. Only the settings' own can be sent, and nothing about them is checked as it is written — the save lands whatever it was told, and an entry the server cannot see is a "resolution" saying so rather than a refusal.

A published share is read through the share viewer, a small static page that draws it in a browser — a gist link on its own shows source. It is not configurable and there is nothing to configure: Verkstead keeps the one copy on its own GitHub Pages, at https://tobico.github.io/verkstead/share-viewer.html, and every link it hands out — the toast, the Share pane and the comment on a pull request — is composed through that, as <share-viewer-url>#<gist-id>. The page is published by .github/workflows/pages.yml whenever crates/server/share-viewer.html lands on main; its address is HOSTED in crates/server/src/sharing.rs, and web/tests/viewing.test.ts is what holds the two spellings together.

Nothing about that is secret: the page is public, the id after the # rides in the fragment and is never sent to the host that serves it, and the share itself is fetched from GitHub by the reader's own browser.

"share_on_done" is whether a conversation's record is published and linked on its pull request when the work settles to Done, which is otherwise something the human presses for. It is the one setting here that is off with nothing configured — an absent key, an absent file and one nothing can parse all mean off — because what it turns on publishes a gist under the human's own account and comments on a pull request other people are reading. It is set from the Git pane, beside the token it is published with.

What it turns on is the wrap-up's own settle rather than the state: a steer into Done shares nothing. It fires once per conversation, gated on the row in share_comments that says a share-to-PR comment has been left on it — written wherever a comment lands, the Share pane's press and the settle alike, and never deleted. So a conversation shared by hand stays quiet, and a share that failed wrote no row and is tried again at the next settle. A failure is a Notice on the timeline naming it; a share that worked writes nothing there.

"cleanup" is what becomes of a conversation the human has archived: a trim that takes its bulk — the full agent output, the transcripts and the session records, which is everything a share never carried — and a delete that takes the whole conversation. Each is a switch and a number of days, each clock counts from the archiving, and unarchiving stops both. The two default the opposite ways about, and an absent key, an absent file and one nothing can parse all say it: the trim is on at three days, because what it takes is what nobody opens twice, and the delete is off at thirty, because it is the one thing here that forgets. A duration that is not a whole number of days — an empty field included — is no duration configured, which is how the default is asked for back; "days_configured" beside the number is what says which of the two is being shown, and the settings page draws the default as a placeholder. Nothing here is refused, a delete sooner than the trim included: the clocks are independent, so the conversation is simply deleted before it was ever trimmed. The sweep reads the file on every pass, an hour apart, so a switch flipped from a phone is in force on the next one without a restart. It reports what it did in the log and asks nothing of anybody — no timeline card, no notice — while announcing on the nudge stream that the record moved, so an open page stops drawing what has been taken. A pass that took something ends with a VACUUM, because SQLite frees the pages a delete emptied inside the file and leaves the file the size it was; a pass that found nothing does not. And it touches nothing outside Verkstead's own record: the git branch stays, and a published share stays published.

"at_once" is how much Verkstead runs at once, which is two numbers and two limits, both in force. "roadmap_stages" is how many stages of one roadmap may be under way together, three where nobody has said, and a place there is held by every stage that is under way whatever it is doing, a stage waiting on an answer included. "conversations" is how many Conversations take a place across the whole server, whatever roadmap or Process they belong to, four where nobody has said — so one roadmap cannot take the whole server by default — and a place there is held by every Conversation with a session running or a driver registered: a grilling, a Review, a Tinker, another roadmap's stage alike. For either, an absent key, an absent file and one nothing can parse all mean the default. Both are read afresh at every start and at every look for a stage waiting on a place — which is a look made as a place comes free, with a slow one behind it for the ways one can come free unannounced — so a change is in force at the next start without a restart and stops nothing already running; "roadmap_stages_configured" and "conversations_configured" beside them are what say whether a number is one somebody typed, and the settings page draws a default as a placeholder. They are the one pair of fields on that page with a floor under them: a limit below one is a roadmap — or a server — that would never start anything, so the page refuses one rather than sending it, and a save carrying one anyway configures nothing. "places_taken" rides back beside them and is nothing a save sets: how many of the server's places are held as of the read, off the same two registers, so that a server holding every place reads as held rather than as stalled. It can stand above the limit, a press going ahead over it and being counted from then on. Its own card, under the instructions.

"conflict_resolution" is what a session sent at a pull request that will not merge is told to do about it: "Merge", which merges the base branch into the work branch and pushes, or "Rebase", which rebases the branch onto its base and force-pushes it with --force-with-lease. An absent key, an absent file and one nothing can parse all mean a merge — a rebase rewrites what reviewers have already read and breaks anything stacked on the branch, and nobody should meet that for never having found the settings page. In config.yaml the word is lowercase, as merge or rebase.

That word is the whole of the answer, in every repo. One repo could once say otherwise, from its own pane on the settings page; an override no settings page draws would be a repo rebasing with nowhere to read why, so it is gone and the file is the only place this is asked. It is picked on the Git pane, under the author — a select of two words, saving on the pick.

The link is composed as a page is drawn rather than written down at the publish. What the record holds is the gist's own URL, so a share published before there was a viewer to compose through links through one now, and the viewer moving retargets every link there is without republishing anything. The gist's own URL travels beside it to the workbench, because the Share pane draws both: the viewer's link is what a reader is sent, and the gist is where the file is and the only place a share can be deleted — Verkstead deletes none of them.

Everything about sharing is on one pane of the workbench, opened by the share icon on the timeline pane's header: where the last share went, and the download, the publish and the share to the pull requests under it. It was four rows of the conversation's actions menu, and none of them did anything to the conversation, which is what that menu is for.

That link is what Share to pull request leaves behind. One press on a conversation whose work is on a pull request publishes a share and comments on every pull request the conversation holds — its own repository's and each companion's — carrying the link and an itemized summary of what is in the file. Comments only: nothing edits a description, and sharing again leaves another comment rather than rewriting the one before it. A pull request the comment could not land on is named beside the ones that worked.

One binary serves both halves: the agent API under /api/v1/, and the web UI on http://127.0.0.1:8422/. It creates verkstead.db in the Data Directory on first run, which --data-dir . above makes the checkout. Leave it running; check it in a third terminal if you like:

$ curl http://127.0.0.1:8422/api/v1/health
ok

The viewer is built into the binary, so pnpm build is what puts a UI at that address. Skip it if only the API matters — the server starts either way, and says on every page that the viewer was not built. While working on the viewer itself, pnpm dev is the better half of this: see The dev loop.

3. Start a conversation (in the browser)

Every Question Set is asked from a Conversation and lands on its Timeline, so there has to be one before an agent can ask anything. Open http://127.0.0.1:8422/ and press New conversation. That opens the composer: drop the Repo dropdown along the bottom of the box and press Open repo, which takes any git repository root the server can read — the path field's dropdown opens on your home to browse for one — and puts the draft on it. Then press Save as draft. The URL then names what it made — /conversations/1 — and that number is the one below.

The same two steps over the API, which is what a script does:

$ curl -X POST -H 'Content-Type: application/json' \
    -d '{"path":"'"$HOME"'/src/verkstead"}' \
    http://127.0.0.1:8422/api/ui/repos
"Added"
$ curl -X POST -H 'Content-Type: application/json' \
    -d '{"repo_id":1}' http://127.0.0.1:8422/api/ui/conversations
{"Started":{"id":1}}

4. Ask (terminal 2)

$ export VERKSTEAD_SERVER=http://127.0.0.1:8422/conversations/1
$ cargo run -p verkstead-cli -- ask examples/questions.yaml

VERKSTEAD_SERVER is the whole of what says which Conversation is asking. A real session never sets it by hand: the orchestrator injects it into the sandbox, scoped to the Conversation the session is running for, so the bundled CLI attributes every Set explicitly without knowing it is doing so. Nothing is inferred from the project or the branch — two Conversations against one repo would be indistinguishable by either.

A wait that goes to plan is silent, and the little the CLI does have to say — reconnecting, or refusing a Set — is on stderr, written as a YAML comment. Stdout carries the Response and nothing else, so an agent can parse it as it stands, even out of the one file its harness merged both streams into.

The command does not return. It has submitted examples/questions.yaml, along with the project and branch it derived from this working directory, and is now holding a long-poll on Question Set 1. There is no timeout: only an answer or a kill ends the wait (ADR-0001).

The Diff on the Set is not the CLI's doing: the server reads it off the Worktrees as the Set arrives — the Conversation's own, and each read-write companion repo beside it, one labeled block each. A Conversation started this way has never been grilled and so has no Worktree at all, which is why the Set below carries no Diff — one asked from inside a real session carries whatever that session has left uncommitted, in whichever repository it left it.

A Set can also arrive on stdin, which is how an agent usually sends one:

$ cat examples/questions.yaml | cargo run -p verkstead-cli -- ask

5. Answer (in the browser)

This is the human's part. Open http://127.0.0.1:8422/ and pick the Conversation the Set was asked from: it is on that Conversation's Timeline, summarised as the table of number, question and answer, and badged waiting on you — the CLI is still holding its long-poll. Press the row and the details pane is the whole ask.

(A Set is also a page of its own at /sets/<id>, which is what a push notification opens on a phone. It draws the same sheet and answers through the same endpoint; what it has that the pane does not is a way back to the Conversation it belongs to.)

The sheet is the whole ask: the Preface, the Diff of the Worktrees the agent asked from, and each Question with its Options. Pick one, or write your own words, or both — an Option with a ★ is the agent's Recommendation, and Accept all ★ Recommendations fills in every question you have not answered yet. Leave a question alone to send it back open; Submit asks you to confirm that before it goes.

Under the last Question is the comment box, for anything about the Set as a whole rather than about one Question — and directly above it, where the agent wrote one, the Postscript: what it wanted to raise without making a Question of it, which the example Set closes with. Nothing there has to be answered, and an empty box says there was nothing to add.

The same Response can go in over the API instead, which is what an integration test or a script does — see examples/response.yaml, which answers every Question in the example Set, leaves one explicitly open, and adds a set-level comment.

6. Delivery

Back in terminal 2, the still-waiting CLI has printed the Response and exited — this is what it prints for the answers in examples/response.yaml:

answers:
- label: Q1
  selected: 1
  free_text: Start in-process. Revisit it the day we run more than two instances.
- label: Q2
  selected: 2
- label: Q2a
  unanswered: true
- label: Q3
  free_text: acct_8f21c3, the nightly export job in `ops/export`.
comment: |
  Ship it behind a flag and turn it on for the one noisy client first.

  That export job hammers the endpoint on purpose, so give it an allowlist
  entry rather than a bigger bucket for everyone.

  On Q2a I genuinely don't know — pick whatever our SDK's retry logic
  already understands, and say in the PR which one that was.

$ echo $?
0

That is the loop. Run step 4 again and Question Set 2 appears on the same Conversation's Timeline, to be answered the same way. To answer it from your phone instead, open the settings page's Remote access section: it says what this machine's Tailscale is doing, its checkbox puts the tailnet name in front of the port this server is listening on — the HTTPS push notifications need to work at all — and the login link is drawn there as a QR code to point the phone's camera at, the address and the Workbench Key together. Nothing to run in a terminal, and nothing to paste: Tailscale itself is the machine's, and a serve set up by hand reads on that page exactly as one set up from it would.

On a Mac the server looks for tailscale over the PATH a session gets — so Homebrew's /opt/homebrew/bin counts even for an app started from the Dock — and then inside the Tailscale app itself, at Tailscale.app/Contents/MacOS/Tailscale under /Applications or ~/Applications. That is where the standalone download, the App Store build and the Homebrew cask all put the CLI, and none of them links it onto a PATH. The one on the PATH wins where both are there.

Where the press is refused for want of the operator grant — Tailscale allows a serve from nobody but root and the tailnet's operator — the pane shows the sudo tailscale set --operator=… that lifts it, naming whichever tailscale was found (on a Mac, perhaps the one inside the app), and the next press is the re-try. The desktop app puts that through pkexec instead — its sidecar is a serve --desktop, and the flag is how the server knows there is somebody at the machine to ask, where a serve started in a terminal has not.

The dev loop

$ cargo test              # unit, schema and end-to-end tests
$ cargo test -p verkstead-server --test suite -- conversations::   # one suite
$ cargo clippy --all-targets
$ cargo fmt
$ nix fmt                 # the Nix files
$ nix flake check         # the viewer's suite, and the NixOS module in a VM

$ blender -b tools/hammer/verkstead-hammer.blend \
    --python tools/hammer/render.py   # the artwork, from the blend file it is modelled in
$ tools/generate-icons.sh     # the favicon and PWA icons, after re-rendering the artwork
$ tools/generate-packaging.sh # the launcher icons, the menu bar template, the icns and the ico

A crate's integration tests are one test binary, suite, and each file under its tests/suite/ is a module of it — so a suite is run by hand by its module's name, as the second line does for the server's conversations suite and nothing else. The server keeps seven files beside suite as test binaries of their own, because what makes each safe is being the only test in its process: those are run by their own names, --test servers_path and the like.

None of the three desktop artifacts a release ships is a script here. All three are the packed Electron app, which is pnpm run pack in desktop/ — one configuration and one command, handed the verkstead the artifact is to carry, and each of them packing only on the platform it is for. The three paragraphs below are what that comes to on each.

The AppImage is the Electron app with a verkstead packed inside it as its sidecar, and which binary that is is the one thing desktop/electron-builder.yml cannot say — so pnpm run pack is told, either as its first argument or in VERKSTEAD_CLI:

$ cargo build --release -p verkstead-cli
$ cd desktop && pnpm run pack ../target/release/verkstead

That build is the headless binary a Release ships, and the release leg hands the same variable the static musl one the CLI matrix published. What comes out is target/electron/out/Verkstead-x86_64.AppImage, packed against the Electron desktop/package.json pins — fetched by the pack rather than taken from the dev shell, whose Electron is a different patch version. The viewer is inside the CLI rather than beside it, so web/dist is that build's business rather than the pack's; and one configuration serves all three platforms, the Linux, macOS and Windows targets all filled in.

The dmg is the same pack on a Mac, and the one place pnpm run pack is given two paths rather than one: the download there is a universal app for both Apple machines, so the sidecar inside it has to be universal too, and the script lipos the two Mac builds into the one staged verkstead before electron-builder builds either half. Both halves come from that Mac's own toolchain, the second Apple target a rustup target add away.

$ cargo build --release -p verkstead-cli \
    --target aarch64-apple-darwin
$ cargo build --release -p verkstead-cli \
    --target x86_64-apple-darwin
$ cd desktop && pnpm run pack \
    ../target/aarch64-apple-darwin/release/verkstead \
    ../target/x86_64-apple-darwin/release/verkstead

What comes out is target/electron/out/Verkstead-universal.dmg, holding Verkstead.app, and Verkstead-universal.zip beside it holding the same app — the dmg is what a human downloads and the zip what an update installs. One app for both Macs, electron-builder building each half and @electron/universal merging the two, which is why the sidecar has to be joined first: that merge refuses a resource that differs between the halves. The bundle's CFBundleIdentifier is net.tobico.Verkstead and its CFBundleIconFile names the staged icns, both of them electron-builder's to write; the release's dmg leg reads that key back off the mounted bundle and has iconutil open what it names, which is the only way a Finder icon that resolves to nothing is visible from outside a Finder window. Nothing in that plist asks for a menu-bar app with no Dock tile, and that absence is the whole of what makes this a regular Dock app where the Rust bundle was one the Dock never held — see the mac: section of desktop/electron-builder.yml, which says which key that was and why it is read back off what was packed (ADR-0020). From a checkout the bundle is ad-hoc signed, because Apple silicon will not execute a binary with no signature at all, with the hardened runtime off beside it, which under an ad-hoc signature would enforce library validation and reject the pre-signed Electron framework for carrying another Team ID. Given the Developer ID certificate and the App Store Connect key in the environment, the same pack signs with the certificate under the hardened runtime and has Apple notarize and staple the app, which is what a Release does (ADR-0027); its log says which of the two it did, and releasing says what the variables hold. It packs on a Mac only: lipo, codesign and hdiutil are the operating system's own tools, and there is no cross build of it from here.

The NSIS installer is the same pack on Windows, and the one artifact of the three that is an installer rather than a file to keep wherever you like: an app whose two ways in are a Start-menu entry and a directory on the user's PATH has to be put somewhere before either of them exists.

$ cargo build --release -p verkstead-cli
$ cd desktop && pnpm run pack ../target/release/verkstead.exe

What comes out is target/electron/out/Verkstead-x86_64.exe, electron-builder's nsis target (ADR-0027), and what it installs goes into the profile with no wizard and no administrator asked for: %LOCALAPPDATA%\Programs\Verkstead, with the launcher Verkstead.exe at its root and the sidecar under resources\cli; an entry in the user's own Start menu; a row under HKCU in Installed apps; and resources\cli on the user's own PATH, so that verkstead ask works in a terminal opened afterwards. The CLI's own directory rather than the install root, because the root is where the launcher stands and Windows resolves a PATH lookup without regard to case (ADR-0020). Per-user because the installer is unsigned, and asking for administrator would be an unsigned program asking for the machine. The install directory's name and that PATH entry are the two things the target cannot be told, so desktop/build/installer.nsh, the include script the configuration names, says them in NSIS: it gives a first install its directory before the template reads one, adds the entry on install without ever adding it twice, and takes it off again on uninstall. It also takes away the Windows Installer package of Verkstead that earlier Releases shipped, where a machine still has one, before anything is extracted. The uninstaller is the row's own, and leaves the Data Directory alone.

It packs on Windows: electron-builder fetches its own NSIS rather than the dev shell carrying one, and MSVC builds what goes inside. The installer is proven on the release's Windows leg, which installs it, asks the install its questions, installs it again over itself and over the last Release's package, and uninstalls it — see Releasing.

An Annex from a development build

A Windows Verkstead makes its Annex by writing a flake into a NixOS-WSL distro and rebuilding it, and that flake pins the Keeper's own release: the verkstead flake at v<version> for the NixOS module, and that release's verkstead-linux-x64 as the binary (ADR-0024, The box on Windows). A development build reports the same version as the last release and is not it, so an Annex made from one would run the released server rather than the one being worked on.

VERKSTEAD_ANNEX_BINARY names a Linux binary to put in instead — a static musl build of the same checkout:

$ cargo build --release -p verkstead-cli --target x86_64-unknown-linux-musl
$ export VERKSTEAD_ANNEX_BINARY=$PWD/target/x86_64-unknown-linux-musl/release/verkstead

Set where the Keeper runs, it is copied beside the flake in /etc/nixos and the configuration runs it; the flake then has no binary input at all, so nothing of the release's binary is fetched. The module still comes from the release tag. What the files are is crates/server/src/annex/flake.rs, and its suite evaluates the written flake with a real nix where one is on PATH — minutes on a cold store, for two nixpkgs, NixOS-WSL and the release.

The sessions suite, and the machine under it

crates/server/tests/suite/sessions.rs is the one suite that is really a hundred and forty small servers, each running a real session in a real sandbox and judged on wall clock. That makes it the one suite whose result depends on what else the machine is doing, so it has two knobs of its own.

VERKSTEAD_TEST_PACE is a multiplier over everything time-shaped in it — the budgets a session is judged idle, rescued and ended by, how long a wait gives up after, and every window a test holds open to prove nothing happened. Unset is 1.0. CI sets 2, because a shared four-core runner running four tests at a time cannot meet a developer's machine's budgets, and a session descheduled past one is judged by the wrong rule and fails a test that is not about the code. Raise it locally if the suite fails on a busy machine and passes on a quiet one; the file's own PACE explains the rest.

Nothing needs setting for the concurrency: under cargo test the suite caps how many fixtures stand at once by itself, at twice the cores it can see, and under nextest every test is a process of its own and nextest's thread count, the machine's cores, does the same work.

$ scripts/soak-sessions.sh        # ten runs, pinned to two cores, under a build loop

The soak is how a change to that suite is shown to hold. Running it again was never how its flakes reproduced — each loaded run failed a different test and every one of them passed alone — so the bar is ten in a row under load rather than one green run. It takes about fifty minutes, which is why nothing runs it for you.

Windows runs an arm of its own, because that suite is cfg(unix): it stands on a mount namespace, a /bin/sh probe and a stty, and a Windows session has none of the three. crates/server/tests/suite/sessions_windows.rs asks a dozen of the same questions of the other machine — a grilling started by pressing the button, run on a pseudoconsole in a profile of the Conversation's own, with a PowerShell script where claude goes — and crates/server/tests/suite/terminal_windows.rs is the console's own pair beside terminal.rs, asking mode con what stty is asked there. Both are cfg(windows), so a Linux or a Mac checkout compiles neither: the windows-2025 job is where they run, and it installs an sccache on the runner for the three of them that are about the build cache — a Compile Server coming up as the session account, a session handed a RUSTC_WRAPPER it can run, and a Rust repository's session really compiling through that server. The last of those wants a rustup-installed Rust on the runner as well, which is what a Windows runner has: it runs the machine's own rustc, through the rustup home a session is granted.

And a Windows checkout needs the session account, once. Every suite that starts a session starts it as that account — sessions_windows.rs, the two boundary suites, account_windows.rs, launcher_windows.rs, and the sandbox::granting::writing tests inside the server crate — and the wizard's sandbox row at the foot of account_windows.rs asks whether there is one at all. None of them can make an account, that being an administrator's call. So a machine that has never run the verb fails there with the line naming it rather than passing quietly, and the verb is run once from an elevated terminal — which is also what the wizard's first step now does for whoever is at the app:

$ verkstead session-account create   # elevated, once per Data Directory
$ verkstead session-account remove   # elevated, and the way to undo it

It is the account of the machine's own Data Directory that they use. An account's name is a fingerprint of the Data Directory it belongs to, and every one of those suites runs against a temporary one — so the account they would be named after is one no verb was ever run for. What they run as instead is the account the human really has, said outright on the Homes a sandbox is built against and with its password copied into the fixture's own secrets.yaml. Run the verb with no --data-dir, which is the default the suites resolve.

The windows-2025 job runs the same verb in a step of its own, a runner being elevated already. Nothing else about a test run is privileged, here or there.

And sessions_windows.rs wants the workspace built first, which cargo test does not do for it: a session comes up on a console made on the far side of the boundary by verkstead session-launcher, so the image a description names has to be a real verkstead.exe rather than the test harness's own — and a server-crate test has no CARGO_BIN_EXE_verkstead to read. It looks for one beside its own binary, in target/debug, and says so where there is none. cargo build --workspace --tests --bins puts it there, which is what the windows-2025 job does before it runs anything.

CI runs the suite through nextest, over legs: five ubuntu-24.04 legs in the Rust job and ten windows-2025 legs in the Windows job. Each leg builds the workspace for itself and runs its share of the tests, dealt out one at a time across every binary by --partition slice:N/5 (or /10). nextest runs every test in a process of its own and tests from every binary at once, so a lock in a static keeps nothing in order there. What has to stay serial — on Windows the suites that start processes as the session account and the registry tests, on Linux the proofs that start a Compile Server on sccache's port — is held serial by .config/nextest.toml, which says why. The doc-tests, which nextest does not run, go through cargo test --doc on each job's first leg. Formatting, clippy and the generated TypeScript's drift check are a Lint job of their own, which writes web/src/api/types.ts with cargo test -p verkstead-render alone. The viewer's fixtures in web/tests/fixtures/ are written by the server's suite, so each Linux leg checks its own working tree is still clean after its tests.

To run the suite the way a Linux leg does, install cargo-nextest (the dev shell has not got it; CI pins 0.9.144) and run:

$ cargo build --workspace --tests
$ VERKSTEAD_TEST_PACE=2 cargo nextest run --workspace --partition slice:1/5 --no-fail-fast

Leave the --partition off to run all of it, and add --bins to the build on Windows, where sessions_windows.rs wants the verkstead.exe beside it.

Both jobs work to the same budget: a typical leg near seven minutes, and timeout-minutes: 15, which catches a hang without failing a leg that drew a slow runner. Each job's comment in .github/workflows/ci.yml keeps the ledger of what each lever came to and says what is left. On Windows the last run measured took 6m03 to 10m08 a leg, and the one lever left there is a larger runner, which is not free; on Linux the next free lever is VERKSTEAD_TEST_PACE, then a sixth leg.

And in web/, which is the Solid viewer (ADR 0003):

$ pnpm install
$ pnpm dev                # the viewer on :5173, /api proxied to the server
$ pnpm test               # the vitest suite
$ pnpm typecheck          # tsc, which the tests do not run
$ pnpm lint               # the wall around the query hook, and nothing else
$ pnpm build              # static assets into web/dist, and the share into web/dist-share

pnpm dev serves the viewer alone and proxies everything under /api to a server on its usual 127.0.0.1:8422, so the two run side by side in two terminals and the browser sees one origin. The proxy is a development thing only: the built assets are served by the server itself, out of the same binary.

Which is pnpm build's output, embedded by rust-embed. A release build compiles it in; a debug build reads it off disk per request, so a cargo run -p verkstead-cli -- serve serves whatever pnpm build last wrote without a recompile — and a checkout that has never built the viewer still builds the server, which then says so on every page instead of serving one.

pnpm build writes three times, out of the same sources. The site goes to web/dist as it always did; the share goes to web/dist-share as one HTML file with its script and its stylesheets inlined (vite.share.config.ts), which is the template the server writes a Conversation into and hands over as a download — see crates/server/src/sharing.rs. Both are embedded the same way and both are allow_missing, so a checkout that has built neither still builds the server; ask it for a share and it says the build is not in the binary. That config refuses to write a document that still points at a file beside it, which is what makes no external requests a property of the build rather than something to remember.

The third is mermaid, on its own, into web/dist-share/mermaid.js (vite.mermaid.config.ts). It is the one thing a Set's page draws for itself and it is three megabytes, so it is the one thing a share does not carry as a matter of course: the share build aliases the package to a stub that reaches for whatever the document is holding, and the server writes the library into a second slot only where something in the record has a Diagram on it — a Set's Preface or a Commit Summary alike. A Conversation nobody drew a picture in stays the size of its own record.

The same file is what a publish puts in a secret gist — see crates/server/src/publishing.rs, where the API makes the gist and git fills it, because the Gists API will not take a file this size. So a publish wants the share build too, and a token with the gist scope on it.

cargo test covers the round trip in-process. nix flake check runs the viewer's vitest suite from the pinned pnpm and node, and boots a VM with the NixOS module enabled to put a Question Set through it again, for the sake of everything the module wraps around that round trip: a unit that starts itself at boot, the Data Directory systemd hands over, a database that survives the service being stopped and started under a waiting agent, a store-path binary serving the viewer that was built into it, and the CLI on PATH with nothing set in the environment. The VM needs a Linux host to boot the guest on, so on macOS that half of the check is absent rather than failing.

The viewer's dependencies are fetched by a fixed-output derivation named by a hash in nix/web.nix — move web/package.json or web/pnpm-lock.yaml and that hash has to move too, and nix will print the one it wanted. That file both builds the viewer and, with runTests on, is the nix flake check above, so the two cannot disagree about a lockfile.

assets/ is vite's publicDir, copied verbatim into the site root: the web manifest, the icons and the service worker. They cannot live under /assets/ with the hashed bundles — a service worker only controls the paths beneath the one it was served from, so one under the bundles' directory could never show a notification for /sets/12, and the manifest and the icons keep the names the phone knows them by. Which is also why the server keeps everything under /assets/ for a year and revalidates everything outside it.

The worker itself does no caching; every list and every Set is read from live SQLite, and a cached copy of one that has since been answered is worse to the human than a failure to load.

The icons are all downscaled by the script above (using ImageMagick from the dev shell) to the sizes the favicon, the manifest and iOS ask for. The smaller PNGs are committed so a build needs nothing but cargo — re-render the artwork and re-run the two cut scripts rather than touching them.

There is one piece of artwork, and every icon in the repository is a downscale of it:

Artwork Cut into Where it comes from
tools/hammer/verkstead-hammer.png icon-32, icon-192, icon-512, apple-touch-icon, and every icon under packaging/ A 1024 square rendered out of tools/hammer/verkstead-hammer.blend by tools/hammer/render.py. The blend file is the mark's source of truth; the render is what the two cut scripts read. Blender comes from the machine rather than the dev shell — see below, where uv does too

It sits in tools/hammer/ rather than beside the icons it is cut into, because assets/ is vite's publicDir: everything under it is served at the site root and, the viewer being embedded, carried inside every binary including the headless CLI. Nothing serves the 1024 square — only the two cut scripts read it, and they read it from the repository — so a copy of it in every binary would be over 600 KB nobody ever asks for. It was under assets/icons while it was the mark's source and had a claim to be the viewer's too; the blend file is that now, and the render is an intermediate like the cut icons are.

The iOS icon is the only output with a field under it and the only one with a margin: iOS ignores transparency and composites whatever it is given onto black, so tools/generate-icons.sh flattens the render onto the chrome's own #21201e — the manifest's theme_color and the document's theme-color tag, so the tile reads as the app's rather than as a third colour — and draws it at 160 inside a 180 square, because iOS rounds the tile's corners itself and the head and the handle both run to the edge of the artwork. That colour is a literal in the script; move theme_color and it has to move too.

The manifest asks for any rather than any maskable: the artwork runs to the edges of its square, and a launcher masking it to a circle would cut the head and the handle's end off. Art with a margin inside it could claim maskable back.

A session working on that artwork drives Blender over the MCP rather than by writing scripts at it, and two committed files are what put that server in front of it. .mcp.json names it — uvx blender-mcp, with DISABLE_TELEMETRY=1 because the package phones home unless it is told not to, and the loopback address and port it dials at the other end — and .claude/settings.json approves that one server by name, an unapproved project server being one a session is prompted about and a session having nobody to prompt. They are approved by name rather than with the blanket key, so a server somebody adds later is not approved by accident.

Both are committed for good. A session reads them when it starts and never again, and Verkstead hands a sandboxed session a copy of the account's configuration with its own servers taken out (ADR 0011), so the repository's own files are the only route: drop them and the next session that has to re-render the icon has no Blender.

Two tools here come from the machine rather than the dev shell, and flake.nix carries neither: blender, which renders, and uv, which .mcp.json runs the server through as uvx and which tools/hammer/serve.py shells out to again for the bundled addon. A nix develop with Blender installed and no uv gets an MCP server that never connects and a serve.py that dies in subprocess.run — which is a confusing way to find out, so it is said here rather than left to be discovered. Neither is in the shell because neither is wanted by a build or a test: the icons are committed, and only a session re-cutting the artwork needs either.

The server connects to Blender lazily, on the first tool call, and what it connects to is tools/hammer/serve.py — the addon the same package bundles, running inside a Blender and listening on that port. Start it before the first tool call and leave it running:

$ blender -b --python tools/hammer/serve.py &   # 9876, unless -- --port says otherwise
$ kill %1                                       # closes the socket and unregisters the addon

It is a script rather than an addon installed into a Blender profile because Blender here has no window and the bundled addon refuses to start without one — its commands run from a timer callback and a background Blender runs no timers. The script supplies that timer itself; its own header says the rest, including why the virtual display the addon suggests is not an option in a Sandbox.

packaging/ is the second tree of generated assets, and it sits outside assets/ deliberately. Everything under that directory is publicDir — served at the web root and, because the viewer is embedded, carried inside every binary including the headless CLI — and a launcher's icons are neither the viewer's to serve nor the CLI's to hold. So the desktop packaging gets a directory of its own: the hicolor icon tree that desktop/scripts/pack.mjs stages as the flat <size>x<size>.png set electron-builder reads an icon directory as — a rename rather than a second set of pixels, so a panel, a menu and the packed image's own .DirIcon are all drawn from the same hammer. No desktop entry: the packed app's is electron-builder's to write, with an Exec of its own that no configuration replaces, and the fields it puts in one are in desktop/electron-builder.yml. Then menubarTemplate.png and the @2x beside it, which are the one thing in the tree that is not a downscale of the drawing: a Mac's menu bar lays a status item out at the size of the image it is handed rather than scaling it to the bar, and it draws a template image — one colour and an alpha channel — black on a light bar and white on a dark one. So that platform gets the hammer's silhouette at the 22 points the bar lays out, and the Template at the end of the name is the whole of how it is asked for: Electron reads the suffix off the file name. Then net.tobico.Verkstead.icns, which pack.mjs stages beside the icon directory for electron-builder to copy into the Mac bundle and name in its Info.plist, and net.tobico.Verkstead.ico, staged beside it for the platform that wants one picture three times over: electron-builder edits it into Verkstead.exe as the launcher's own resource, nothing installed beside an exe being what Alt-Tab and the taskbar draw it with, and the installer names that resource as the row's DisplayIcon, so the row in Installed apps is drawn with it, and as the Start-menu shortcut's icon. It is written by tools/generate-packaging.sh from the same hammer, and committed for the same reason the viewer's icons are. That script rewrites the whole directory from nothing on every run — so a size that stops being generated stops being committed, and nothing under it is ever edited by hand.

The icns is written by the script itself rather than by iconutil, which is a Mac's: the format is a header and a PNG per icon slot, so it is generated in the dev shell alongside everything else here rather than on the one platform that reads it. The ico is magick's own output, that tool being in the dev shell and having no such restriction — and the sizes it is handed are those same committed downscales, so every platform draws the same pixels.

The tests run the real server in-process, so the round trip they check is the one an agent gets — including the quickstart above, whose example files crates/cli/tests/suite/ask.rs drives end to end, taking the human's part over the API the viewer's Submit posts through.

verkstead-render is everything the server does to what an agent wrote before it leaves: markdown to sanitized HTML, the Diff parsed and highlighted, and the view types the viewer draws a Set from. It knows nothing of the store, the router or the viewer, so it is the seam the browser never reaches across — everything past it is HTML the viewer only has to put in the page.

Two things under web/ are written by cargo test rather than by hand, and both are committed so that the diff is the review. web/src/api/types.ts is those view types as TypeScript, generated by ts-rs — the viewer imports them and declares no shape of its own, so the two languages cannot come to disagree about a field. web/tests/fixtures/ holds a payload of each kind, rendered by the real /api/ui/ endpoints, which is what the vitest suite is fed: a component test against a hand-written mock proves only that the mock and the component agree.

Every query in the viewer is made with useReading from web/src/freshness.ts rather than with the hook underneath it. A Nudge invalidates what the page is showing (ADR 0009), so each query has to say what a re-read does to what is drawn: freshness names the key the re-read is merged by, or says "static" where the payload cannot change. There is no default — a query that says neither does not typecheck — and web/eslint.config.js is the other half of the same rule, refusing the raw hook everywhere but the wrapper's own module. That is the whole of what pnpm lint checks.

Which queries a Nudge invalidates is one table in web/src/nudge.ts, keyed by the kind the server sent. The kinds themselves are a Rust enum in crates/schema/src/nudge.rs, so adding one is two edits — the variant and the announce site on the server, and a row in that table on the viewer. A kind with no row falls back to reading everything, which is what an older page does against a newer server; web/tests/nudging.test.tsx sweeps the generated fixture and fails on a kind the table has forgotten. Nothing polls: what stands behind a Nudge that never landed is the catch-up on reconnect and on the page becoming visible again.