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.
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.
$ nix developEverything 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.
$ (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=./skillsworkbench= 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:8524Two 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/devicesUnlink 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.mdThe 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.yamlA 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 arrivedA 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-cargolanguages 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
okThe 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.
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}}$ export VERKSTEAD_SERVER=http://127.0.0.1:8422/conversations/1
$ cargo run -p verkstead-cli -- ask examples/questions.yamlVERKSTEAD_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 -- askThis 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.
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 $?
0That 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.
$ 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 icoA 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/verksteadThat 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/verksteadWhat 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.exeWhat 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.
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/verksteadSet 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.
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 loopThe 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 itIt 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-fastLeave 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-sharepnpm 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 addonIt 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.