A local web page over a coordinator's event log: issues and their status, decisions waiting on you, agent runs, estimated spend, the land queue, deploys and reports. It listens on 127.0.0.1 only (it refuses any other address). On an always-on machine, people sign in to the hub with their own GitHub account (see Sign-in); the dashboard's token is kept only as the owner's break-glass on the machine itself.
worklane dashboard # from inside the checkout, or with --root <dir>It reads the checkout's own log and opens the browser on port 4317.
An instance's log lives in its coordinator user's home, which only that user can read. So the dashboard runs as that user, on the machine where the instance runs:
bash scripts/setup/dashboard.sh <name> [<your GitHub login>]- It starts
worklane dashboard --instance <name> --no-openaswl-<name>, in the foreground (Ctrl+C stops it). - The port is fixed by the instance's name (4400-4899), so a tunnel set up once keeps working.
--porton the CLI overrides it. - Its token is the owner's break-glass: whoever holds it, on this machine, is the owner. Your GitHub login only names who answered; without it, the project's default owner. For anyone else, use the hub's sign-in.
- Answers are recorded in that instance's log, as its coordinator user, like
decideon the command line.
The dashboard never listens on the network. To open it from your laptop, forward the same port over ssh:
ssh -N -L <port>:127.0.0.1:<port> <you>@<always-on-host>Then open the URL dashboard.sh printed (http://127.0.0.1:<port>/) on the laptop. The page asks once for the dashboard key: it is the dashboard-token file in the instance's state directory, readable only by its user. The page sends it in the Authorization header, the live streams included, and keeps it in that browser tab only. A key never goes in a URL: any request whose URL carries one (?t=, ?token=, ?key= and the like, or the key's value under any name) is refused with a 401, on every route of the dashboard and the hub, so a key never ends up in history, proxy logs or a referrer. The key stays the same across restarts; delete dashboard-token to issue a new one.
Each instance has its own port and token: run one dashboard.sh and one tunnel per instance.
To see every instance on the machine from one page and one tunnel, an admin reviews and runs, once:
sudo bash scripts/setup/dashboards.sh --setup --public-url https://<machine>.<tailnet>.ts.net # first: the sign-in App (below)
sudo bash scripts/setup/dashboards.sh [--machine-owner <login>] <name> [<name>...]The second command takes the sign-in App's client id and the public URL from where setup mode stored them; --client-id and --public-url override them (and are needed when the App was made by hand).
- Each instance gets its own dashboard service,
worklane-dashboard-<name>.service, running aswl-<name>. It's the only process that reads that instance's log and records answers in it. It replaces runningdashboard.shin a terminal; don't run both, because they use the same port. - A viewer user,
wl-dash, runs the hub,worklane-dashboard-hub.service, on port 4399. The page shows an instance switcher, and every call, answers included, is forwarded to the selected instance's own dashboard, which applies its own checks. - Every page shows the selected instance only: its Board columns and counts, its test runs, what waits on you, and so on. The agent and test slots are shared by every instance on the machine, so each instance's pages count all of them, but name only its own holders; another instance's show as "another instance or job on this machine". All instances, at the bottom of the switcher, is off unless you choose it: the Board, its test runs and the Machine page's instances table then show every instance you have a role on, each item labelled. The sidebar header then reads "All instances", and the Claude account card says it covers every instance; its steps for one instance (choosing, adding or signing in an account, failover) wait until you pick that instance. Picking an instance turns it off again.
- A live stream re-reads the person's role (from the 5-minute cache) before each change goes out, and ends once they have none.
wl-dashcan read each instance'sdashboard-tokenfile and traverse the directories leading to it, through ACLs. It can't list those directories, read any log, config or credential, or write anything outside its own home. The script checks all of this before it finishes. No instance user gets new access, so no instance can read or write another's log.- The hub never hands an instance's token to the browser.
- One hub and one dashboard service per instance, whoever signs in: no process per person.
After adding an instance, run the script again with the full list.
People open the hub at its Tailscale HTTPS address and sign in with GitHub. On every request, each instance's own dashboard decides their role there from their permission on that instance's repo:
| Permission on the repo | Role | May |
|---|---|---|
| admin | owner | everything: settings, held plans, high-risk (L3) merges, the console (message and stop a run; asks, root steps), filing chat drafts, the audit log |
| maintain, write | writer | read runs, throughput, reports, intake and the digest; message (steer) a running agent, not stop it; ask the chat; answer ordinary decisions and intake approvals; save views |
| triage, read | viewer | the tasks (without costs), decisions, PRs (each one's state only: never why it waits, its checks, fix runs or conflicts, or the auto-merge state; src/prs-viewer.ts) and deploys; not the service log, the machine settings, spend, the budget, agent runs, which agents hold the slots (only the counts), the coordinator's error text (only a count) or decisions' receipts; of the event log (the issue timeline, Activity, the live stream) only an allowlist of event types and fields (src/viewer-events.ts: never runs, agents, research, chat, console or costs), and of the settings only the project part; of the agents' Claude account only whether one is set. No writes at all; no Watch on the Board, and Board cards without runs, usage, diffs or checks |
| none, or any error looking it up | none | nothing: the instance isn't listed, and any call to it gets 403 |
- The permission is read with the instance's own installation token (
GET /repos/{owner}/{repo}/collaborators/{login}/permission), never with the person's token, and cached at most 5 minutes per instance and login. So someone removed from the repo loses access within 5 minutes. project.ownersin the repo's config no longer grants anything on the dashboard; it still names who decisions are addressed to.- The hub lists only the instances where the person has a role. A role sent by the browser is never trusted; the hub names the person to the instance in a header it sets itself.
- Sessions: a
__Host-sessioncookie, HttpOnly, Secure and SameSite=Strict, holding a 256-bit random id that the hub stores only as an HMAC under the session secret. A session ends after 12 hours without a request, or 7 days after sign-in, whichever is first. Sign-out deletes it on the server. - Every write (POST, PUT, DELETE) needs the session's CSRF token (derived from the session, so another session's is refused), and a write whose
Originisn't the hub's public URL is refused. - The audit: every write records the GitHub login, the role, the instance and what was done (decision answers, settings and machine changes, chat questions and filed drafts, console messages and stops, saved views) as a
dashboard.auditevent in that instance's log. Owners see it at the bottom of the Activity page. - The person's GitHub token is used once, to learn their login (
GET /user), then revoked. It is never stored or logged.
The hub can't sign anyone in before its sign-in App exists, and GitHub's manifest flow, which makes the App in a few clicks, needs a running hub to send the browser back to. Setup mode breaks that loop. With Tailscale HTTPS set up (below):
sudo bash scripts/setup/dashboards.sh --setup --public-url https://<machine>.<tailnet>.ts.net- It starts
worklane-dashboard-hub-setup.serviceaswl-dash, on 127.0.0.1:4399 (the porttailscale serveforwards). It has no instances and no sign-in: it serves one page, which makes the sign-in App. - It prints the hub's URL (a line
setup-mode hub: <url>) and the path of a one-time setup token,~wl-dash/.local/state/worklane/dashboard-hub/setup-token, 0600,wl-dash's. Only someone with root on the machine can read it:sudo catit and paste it into the page. The token is never printed, logged, put in a URL (a link carrying?token=is refused) or passed as an argument. - The token works once. The browser that used it gets a setup session: a
__Host-setupcookie (HttpOnly, Secure, SameSite=Lax so GitHub's redirect back carries it) for 2 hours. The token file is deleted. Ten wrong tokens lock the page until the next--setup. Every form checks theOriginand the page's CSRF token. - The page asks which GitHub account or organization owns the App, then posts the manifest to GitHub: no permissions, no webhook, the callback
<public URL>/auth/callback. GitHub sends the browser back with a one-time code. The hub exchanges it, and stores the client secret, a session secret, the client id and the public URL in~wl-dash/.config/dashboard-signin/(0700; each file 0400,wl-dashonly). The App's private key isn't kept. - Setup mode then turns itself off: the process exits, and nothing answers on 4399 until the real hub starts. It also turns off if the client secret appears some other way (
credentials.sh dashboard-signin). It won't start again while the secret exists:--setupis refused. - Then run
dashboards.sh <name>...as above. It removes the setup-mode unit and starts the real hub with the stored client id and URL. The setup page shows the exact command.
No https origin yet. Before Tailscale is set up, --setup --loopback takes the place of --public-url (the install command does this by itself when it finds no origin):
sudo bash scripts/setup/dashboards.sh --setup --loopback- The same unit, page and one-time token, on
http://127.0.0.1:4399/. From your own computer,ssh -L 4399:127.0.0.1:4399 <you>@<server>first, then open that URL. - It answers only a request made on the machine itself (through the tunnel) to a loopback name: anything carrying a proxy's headers (
tailscale serveadds them) or anotherHostis refused, as on the break-glass port. Its session cookie issetup-loopback(HttpOnly, SameSite=Strict; not Secure, since the page is plain http on loopback). - The sign-in App step never starts GitHub's manifest flow here: GitHub sends the browser back to an https address, and there isn't one. The page offers the choice: an origin of your own (a name whose https server forwards to the hub's port on this machine), given as
--public-url https://<host>to the install command run again; or Tailscale and HTTPS (the wizard's screen 7), as root commands:scripts/setup/tailscale.sh install,upandserve, then the install command (or--setup --public-url https://<name>) again. Either way setup mode restarts at the origin with a new token, and the App step goes on there.
The first-run page's "Dashboard sign-in (GitHub App)" step shows the --setup command, and says when the hub is in setup mode.
A machine's first project. The agents' App is made through the manifest flow, which comes back to the hub, and add-project starts the hub in its last step (an instance's own dashboard can't start before its checkout exists, and the checkout needs the App). So when the machine has no hub yet, add-project's App step starts the hub alone first, with dashboards.sh --no-instances and what setup mode stored, then goes on; its last step adds the instance to the hub as before. The order for a first project is: dashboards.sh --setup, the sign-in App, add-project.sh <name> <owner/repo> (which starts the hub, then asks for the two GitHub clicks). If there is no sign-in App yet, the App step stops and names the --setup command. --no-instances takes no names and is refused once the hub serves an instance. A second project is unchanged: the hub is there, and is never started alone.
By hand instead, create a GitHub App used only for "Sign in with GitHub" (GitHub: Settings, Developer settings, GitHub Apps, New GitHub App):
- Callback URL:
https://<machine>.<tailnet>.ts.net/auth/callback, exactly the hub's public URL plus/auth/callback. - Expire user authorization tokens: on (the default).
- Request user authorization (OAuth) during installation: off. Enable Device Flow: off.
- Webhook: off (uncheck Active).
- Permissions: none. Leave every repository, organization and account permission at "No access". The App is never installed on a repo; the dashboard reads repo permissions with each instance's own App.
- Where can this GitHub App be installed: only on this account.
Then generate a client secret on the App's page, and install it for the hub's viewer user only:
sudo bash scripts/setup/credentials.sh dashboard-signinIt asks for the client secret (not shown), writes it to ~wl-dash/.config/dashboard-signin/client-secret (0400, wl-dash only), and generates a session secret beside it the first time (--rotate-session makes a new one, which signs everyone out). The client id isn't secret: pass it to dashboards.sh --client-id.
Instead of creating an App by hand, sudo worklane app-manifest <name> <owner/repo> makes the instance's agent App, and sudo worklane app-manifest <name> <owner/repo> --signin --public-url https://<machine>.<tailnet>.ts.net makes the sign-in App. With the hub running, it prints a link. A machine owner opens it, signed in to the dashboard, and approves the App on GitHub. The link works once and lasts 10 minutes. GitHub then sends the browser back to the hub's /setup/app-manifest/callback. The hub exchanges the code, and the command writes the result for its one user, at the paths above: the agent App's key for wl-<name> (0400), or the sign-in App's client secret for wl-dash. Neither is ever logged. For the agent App, it then prints the install link: install the App on exactly <owner/repo>. It finds the installation and runs the same check as credentials.sh (app-permissions.mjs), refusing an installation that reaches any other repo. Only after that check passes does it write credentials.yaml. The agent App asks for contents, issues, pull requests and checks write, plus actions and metadata read: never Workflows, Administration or Actions write. credentials.sh stays the manual fallback.
The Setup console's Add project is the screen for the agent App (first-run screen 5). Pick the repo from the list, or type a private one as owner/name: GitHub lists only public repos to the dashboard, which keeps no GitHub token. Add project then makes the App through the manifest flow only, and GitHub's install page opens with that repo preselected; an installation that reaches any other repo is refused. GitHub's one-time code is exchanged by the hub and used once; the App's private key, client secret and webhook secret go straight to the coordinator user's own files (~wl-<name>/.config/<name>-app/, each 0400 in a 0700 directory) and never to a URL, a log, an argument, an event or the repo. The screen is optional: sudo bash scripts/setup/add-project.sh <name> <owner/repo> runs the same steps at a root shell, and adding the same project again picks up where it stopped.
The hub is reachable only over your tailnet, never the public internet. The steps are below by hand; Tailscale and HTTPS from the dashboard does the same from the Machine page, and scripts/setup/tailscale.sh from a terminal.
-
In the Tailscale admin console, under DNS, enable MagicDNS and HTTPS certificates.
-
On the machine, forward HTTPS on the tailnet to the hub's sign-in port:
sudo tailscale serve --bg --https=443 http://127.0.0.1:4399
Tailscale terminates TLS with the machine's MagicDNS certificate, so browsers see
https://<machine>.<tailnet>.ts.netand the Secure cookie works. The hub itself still listens on127.0.0.1only. Pass that URL todashboards.sh --public-url. -
To let a teammate in, share the machine with them (the machine's menu in the admin console: Share), and limit what they reach with an ACL in the tailnet policy file, for example:
{ "action": "accept", "src": ["autogroup:shared"], "dst": ["<machine>:443"] }They then sign in with their own GitHub account; what they may do on each instance follows their permission on its repo.
The hub's token still works, as the owner, but only on the hub's separate break-glass port, 4398, on the machine itself:
-
tailscale serveforwards only port 4399, so every request on 4399 arrives from127.0.0.1. That's why the token is never accepted on 4399 at all: "from localhost" there would include every tailnet user. -
Port 4398 also listens on
127.0.0.1only, is never forwarded, and refuses any request carrying a proxy's headers (Tailscale-User-Login,X-Forwarded-For,X-Forwarded-Proto,Forwardedand the like), whatever its address. Don't pointtailscale serveat it. -
To use it from your laptop, tunnel to it over ssh and open the break-glass URL; the page asks once for the key (it is in
~wl-dash/.local/state/worklane/dashboard-hub/dashboard-token) and sends it in a header, never the URL:ssh -N -L 4398:127.0.0.1:4398 <you>@<always-on-host> # then: http://127.0.0.1:4398/ (give the key on the page)
-
Each instance's own dashboard applies the same rule to its own token: with no person named, it's the owner's break-glass, refused through a proxy.
Siri was removed (#743). dashboards.sh --siri and the hub's --siri-port are accepted and ignored, so an older
command line or unit still runs, and no gateway listens. Each instance's dashboard deletes its own Siri device token
store and gateway token when it starts, and an export never carries them. Every dashboards.sh run drops
--siri-port from the hub's unit and deletes any Siri files left; tailscale.sh unserve-siri takes away the
tailnet's 8443 forward. Until both have run, doctor fails under "voice and Siri removed" and names each leftover.
A machine change from the page (below) must come from one of the machine devices: the Tailscale devices the owner lists in the hub's unit, as root on the machine:
sudo bash scripts/setup/dashboards.sh --machine-devices tag:owner-laptop,my-phone --client-id <the App's client id> --public-url https://<machine>.<tailnet>.ts.net [--siri if the hub's unit has --siri-port] [--hooks if it has --hooks-port] <name> [<name>...]On a hub already set up, keep --siri and --hooks when its unit (/etc/systemd/system/worklane-dashboard-hub.service) has --siri-port and --hooks-port: leaving one out turns that gateway off.
--machine-devices takes Tailscale tags (tag:...) and/or MagicDNS node names, comma-separated. Prefer tags that only you can assign in your tailnet policy, because a node can be renamed. The list lives in the hub's systemd unit on the machine, never in a repository. With no list, every device is refused, and only the break-glass may change the machine. The hub keeps its full sandbox (NoNewPrivileges, ProtectSystem=strict, ProtectHome=read-only, PrivateTmp) either way. Add project, a rerun through Tailscale setup and instance removal keep the list as the hub's unit has it.
How the device is identified: the hub sits behind tailscale serve --https=443 http://127.0.0.1:4399 (Tailscale HTTPS, above).
- A request counts only if the kernel says tailscaled (root) opened the connection, from
/proc/net/tcp. A program on the machine connecting to the port itself is refused, whatever headers it sends. - On such a connection, serve's
X-Forwarded-Forholds the peer's Tailscale address. serve replaces anyX-Forwarded-Forthe browser sent. - That address is looked up with
tailscale whois --jsonfor the node's name and tags. - The
Tailscale-User-*headers are never used: they name a person, not a device, and serve sets them only for user-owned nodes.
A request through an ssh tunnel, or on the break-glass port 4398, can't be tied to a device. On 4398 the break-glass itself is allowed instead.
The Setup console no longer opens a terminal (#742). It was a login shell as the machine's admin user, which the hub's viewer user started through a sudoers rule and a root-installed console shell, and it made the hub's unit drop its sandbox. For a shell on the machine, use ssh; the break-glass (port 4398, through ssh) stays.
An install that had it (--console-user in the hub's unit, or /usr/local/libexec/worklane-console-shell present) keeps its sudoers rule until root runs dashboards.sh again. The first engine update replaces the console shell with a stub that refuses every command (the updater keeps each root helper the machine has in step, with its owner and mode), so the rule then reaches only that stub. Until the rerun, the Setup console shows its owner a notice, and doctor fails (owner terminal, exit 1) with the rerun first.
doctor prints the rerun exactly, built from the hub unit's own ExecStart (/etc/systemd/system/worklane-dashboard-hub.service): its devices, its client id and URL, --siri and --hooks when it has those gateways, and its instances, every value single-quoted. It reads the unit only if it is root's regular file that no one else may write, and it builds nothing from a unit it can't read exactly (an option dashboards.sh doesn't write, quotes or expansions in ExecStart); it then still fails, and shows the same command with placeholders. By hand, fill in each <...> and [...] from that ExecStart:
sudo bash /opt/worklane/current/scripts/setup/dashboards.sh [--machine-devices <the hub unit's --console-devices or --machine-devices, if it has one>] --client-id <its --client-id> --public-url <its --public-url> [--siri if it has --siri-port] [--hooks if it has --hooks-port] <each instance name in its --hub>...
Leaving out --siri or --hooks that the unit has turns that gateway off (dashboards.sh removes the viewer's read of its tokens). The rerun removes the console shell (or its stub) and its sudoers rule (/etc/sudoers.d/worklane-console) and writes the hub's unit with its full sandbox. An older command line still runs: --console-user is accepted and ignored, and --console-devices is read as --machine-devices, with no device added.
The machine itself (the agent and test-run slot caps, engine updates) belongs to its machine owners, a list of GitHub logins, not to each instance's owners:
sudo bash scripts/setup/dashboards.sh --machine-owner <login>[,<login>...] [--machine-devices <the hub unit's, if it has one>] --client-id <id> --public-url https://<machine>.<tailnet>.ts.net [--siri if the hub's unit has --siri-port] [--hooks if it has --hooks-port] <name> [<name>...]The list goes to /etc/worklane/machine-owners.json ({"owners": ["<login>", ...]}, root:root 0644; src/machine-owners.ts). A rerun without --machine-owner keeps it. A machine change from the dashboard (Settings, or a root step) needs all of:
- the instance's owner, who is also a machine owner;
- a signed-in session, with its CSRF token;
- one of the machine devices (
--machine-devices, above), as the hub identifies it.
The instance's dashboard checks these before it records the request, and the coordinator checks the machine owner again before it runs it. The break-glass (port 4398, on the machine) is still allowed. An instance owner who isn't a machine owner sees the machine settings read-only, with the machine owners' logins. With no file (or one anyone but root may write), there are no machine owners, and only the break-glass may change the machine.
The first install sets the first machine owner: sudo bash scripts/bootstrap.sh --hardened <name> <owner/repo> asks for your GitHub login (or reads it from gh api user when gh is signed in, and asks you to confirm; --machine-owner <login> skips the question), writes it with scripts/setup/machine-owners.sh --first, and keeps a list that is already there. At the end it prints the dashboard's URL (--public-url, else this machine's Tailscale name) and how to make the sign-in App: through GitHub's manifest flow (worklane app-manifest <name> <owner/repo> --signin, which it runs itself once the hub is up), or by hand (credentials.sh dashboard-signin). After that, everything happens in the dashboard.
The Setup console's Add project sets up a new instance on this machine: a name (^[a-z][a-z0-9-]{0,20}$) and the GitHub repo (owner/name), checked in the page and again on the server. It's enabled only for an owner of the instance you're on who is a machine owner, signed in, on one of the machine devices (the same rules as a machine change, above). Anyone else sees it disabled, with the reason and the machine owners' logins.
A submit is recorded as the machine request add-project <name> <owner/repo> (POST /api/machine/add-project; the view is GET /api/add-project, src/console/add-project.ts), with an audit entry; a refused one is audited too. The dashboard never runs it. The coordinator runs the machine helper, which runs the installed engine's scripts/setup/add-project.sh as root. One project at a time.
The view then follows the run live, step by step, in the script's order: instance, app, claude-token, checkout, policy, service, test-user, machine-helper, dashboards. Each step's line from the script is an add_project.step event in the instance's log, so every step is on record. What it needs from you shows in the same view, as asks: GitHub's two approve links (create the App, then install it on exactly that repo). The agents' Claude login is never asked for here (issue #711: the dashboard never takes a Claude credential). The claude-token step waits instead, naming what a root admin runs on the machine: sudo bash scripts/setup/credentials.sh <name> claude (the token claude setup-token prints, at its hidden prompt), or sudo -u wl-<name>-agent -i claude auth login, or, to bill the agents to an Anthropic API key, sudo bash scripts/setup/credentials.sh <name> api-key (the step counts that as done once the key is set up and claude auth status as the agent user reports an API-key login). Then add the same project again: it goes on from there. An ask for the token that an earlier version left open is withdrawn when the coordinator starts, and nobody can answer it before then (docs/auth.md, "Existing installs"). If a step fails, the view names it and the command to retry it. Fix what it says and add the same project again: the steps already done are skipped.
On a machine whose sudo is older than 1.9.10 (the first sudo that reads a rule's ^...$ arguments as a pattern), machine-helper.sh doesn't install the helper's add-project rule. It prints REFUSED for it and records this in /etc/worklane/helper-rules.json. Add project then shows no button that could only fail at sudo. It shows why, and the root command to run on the machine, with the name and repo you type filled in: sudo bash /opt/worklane/current/scripts/setup/add-project.sh <name> <owner/repo>. The server refuses a submit with the same command, and a run that sudo refuses on a machine set up before that record says so too.
The First run page (/first-run) lists every setup step, each with its state checked live. A step that the page can fix has a screen of its own: a button that shows its plan, a form, or Start. Every screen works three ways:
- Skip it. An owner can press Skip on any step that isn't done. The page then shows it as skipped and stops counting it as left; Undo skip brings it back. A skip changes only the page:
worklane doctorandworklane initstill check the step. - Run it again later. Settings has a Setup screens list with each screen's state. Its link opens that step on the first-run page (
/first-run?step=<id>), even when the step is done, so it can be run again. - Do it without the page. Each screen names the command or config key that does the same thing, and the table below lists them all. The test
test/setup-automation.test.tschecks that the table and the page agree.
A config change from the page (a checkout). The first-run page acts on the config the checkout has now, read again on each request, not the one the dashboard started with. The Repo config form proposes a change as a PR from a branch of its own (worklane/config-...). Every config PR the page proposes (this one, and setup mode's setup PR) is then followed the same way under Config PRs (src/config-pr.ts):
- Merge PR #n here shows the
gh pr mergecommand, then merges on Confirm. It merges only a PR this page opened itself, as its own record has it (the number and the commit it pushed), into the default branch; a PR whose branch is merely named like the page's is neither listed nor merged, and the merge commit's subject names the login that confirmed it, recorded as their approval (also in the audit). - Merged anywhere (here, on GitHub or from the CLI), the page sees it on its next look. It fetches and fast-forwards the checkout's default branch to origin's (
src/checkout-sync.ts), then says who merged it, when, and the checkout's commit. It is only ever a fast-forward: never a reset, a merge commit or a rebase. - If the checkout has uncommitted changes to tracked files, the page names them and offers Fast-forward now for when they're gone. If it has commits origin doesn't have, it says by how many on each side and gives the command to see them. If it's on another branch, it says so.
- A merged PR is never an error. A closed one says it was closed without merging.
The next step reads the new config with no restart and no git pull.
Before the config exists (setup mode). A checkout with no .worklane/ yet has no config for the dashboard to load. In that case worklane dashboard --root <repo> starts in setup mode and prints http://127.0.0.1:<port>/first-run, with no key in it, and the path of the dashboard key file (only you can read it). The page asks for the key once, in a hidden field or by choosing that file, sends it as a header and keeps it in that tab only. worklane init offers to open the same page when it stops at the repo config. Setup mode works like this:
- The first-run page is the whole dashboard, and every address shows it.
- From the page you can:
- pick the repo's GitHub origin;
- answer auto-config's questions;
- draft the config, which runs the project's commands on a scratch copy of the default branch, so it asks for a confirmation first;
- merge the setup PR that the draft opens.
- Start is refused until the config exists.
- Once the merged config is in the checkout, the normal dashboard takes over at the same address with the same key, and the page reloads.
| step | mode | without the page |
|---|---|---|
| Tools: git, gitleaks, claude (and gh, locally) | local, server | worklane init --yes (local: the same pinned tools); on a server, scripts/setup/tools.sh through bootstrap.sh --hardened |
| Signed in to Claude | local | claude auth login (in a terminal, as this user); an API key: worklane init --answer agent-runtime=api_key --answer api-key-file=<path> |
| The agents' Claude login | server | scripts/setup/credentials.sh <name> claude (paste the token claude setup-token prints at its hidden prompt), or scripts/setup/add-project.sh <name> <owner/repo> at a root shell (its claude-token step); an API key instead: scripts/setup/credentials.sh <name> api-key |
| Signed in to GitHub (gh), able to push | local | gh auth login --web (in a terminal, as this user) |
| The repo has a GitHub origin | local | git remote add origin https://github.com/<owner>/<name>.git, then git fetch origin |
| The agents' GitHub App | server | sudo bash scripts/setup/add-project.sh <name> <owner/repo> (its app step: the App through the manifest flow, installed on that one repo) |
| Default branch known | local | git fetch origin <branch>, then git remote set-head origin --auto |
| Auto-config questions answered | local | worklane init --mode local |
| .worklane/ valid and on the default branch | local, server | worklane init (drafts the config and proposes it as a PR), then gh pr merge <number>, then worklane init again (it fast-forwards) |
| Hooks for local runs installed | local | worklane install |
| Owners and writers set | local, server | a reviewed commit (PR) to .worklane/config.yaml (owners.writers, required_checks) and .worklane/agents.yaml (daily_budget_usd, roles.workers count and model) |
| Required checks named | local, server | a reviewed commit (PR) to .worklane/config.yaml (owners.writers, required_checks) and .worklane/agents.yaml (daily_budget_usd, roles.workers count and model) |
| Budget, models, guardrails, review levels | local, server | a reviewed commit (PR) to .worklane/config.yaml (owners.writers, required_checks) and .worklane/agents.yaml (daily_budget_usd, roles.workers count and model) |
| Pre-commit secret scan | local | worklane install --git-hooks |
| Guardrail fingerprints fetched | local, server | worklane guardrails refresh |
| Default branch protected | local, server | gh api --method PUT repos/<owner/repo>/branches/<branch>/protection --input <body.json>, the body from config.yaml required_checks and protection.strict (src/branch-protection.ts classicChecks) |
| The instance's policy | server | the instance's policy.yaml: budget.daily_usd, agents.max_workers, land_mode, auto_merge, main_watch |
| Discord intake and chat | server | sudo bash scripts/setup/credentials.sh <name> intake-discord (the bot token), then intake.yaml in the instance home (guild, channels, owners, people; docs/intake.md) and a restart of the instance |
| Slack intake | server | sudo bash scripts/setup/credentials.sh <name> intake-slack (the two tokens), then intake.yaml in the instance home (slack.enabled, team, channels, allowed_senders; docs/intake.md 2.7) and a restart of the instance |
| Linear intake | server | sudo bash scripts/setup/credentials.sh <name> intake-linear (the API key), then intake.yaml in the instance home (linear.enabled, team, label, allowed_senders; docs/intake.md 2.8) and a restart of the instance |
| Reachable over Tailscale (HTTPS) | server | sudo bash scripts/setup/tailscale.sh install, then up, serve and public-url (one step each; status reads only) |
| Engine updater set up | server | sudo bash scripts/setup/updates.sh [--channel release] [--signer <principal> ssh-ed25519 <public key>] (or --confirm-signer SHA256:<fingerprint> for a signer staged on the Machine page), then sudo /usr/local/libexec/worklane-machine set-updates on |
| The instance's coordinator running | server | sudo bash scripts/setup/service.sh <name> (add-project.sh runs it) |
| The coordinator running | local | worklane up |
| An issue ready for the harness | local | gh issue create --label ready --title "<what to do>" --body-file <a file with **Outcome:** and a ```done_when block> |
The first-run wizard's Tailscale screen, for a server. It is the Machine page's Tailscale and HTTPS card (/machine#tailscale; the first-run step "Reachable over Tailscale (HTTPS)" opens it, and it stays there to run again). It's optional: on a machine without Tailscale, or if you never confirm a step, nothing changes and the hub keeps its URL.
It's visible only to machine owners: the server sends everyone else none of its data (GET /api/tailscale, src/tailscale-setup.ts), and a local checkout has no such screen. It shows whether tailscale is installed and up (what scripts/setup/tailscale.sh status --json says, read as the instance's own user), the hub's URL now, and four steps in order. Each step shows what it changes first and needs a second click; each is a machine request, gated like any machine change (an owner here who is a machine owner, signed in, on an allowed device; or the break-glass), audited, one at a time:
| Step | Machine request | What runs, as root | Without the dashboard |
|---|---|---|---|
| Install Tailscale | tailscale install |
Tailscale's apt repository for this distribution, its signing key checked against the fingerprint pinned in scripts/setup/lib.sh, then the pinned version of the package. No install script. A tailscale already there is left alone. |
sudo bash scripts/setup/tailscale.sh install |
| Log this machine in | tailscale up |
tailscale up with no auth key. It prints a login link and waits 10 minutes. |
sudo bash scripts/setup/tailscale.sh up |
| Put the hub on HTTPS | tailscale serve |
tailscale serve --bg --https=443 http://127.0.0.1:4399, and nothing else. |
sudo bash scripts/setup/tailscale.sh serve |
| Set the hub's public URL | tailscale public-url |
dashboards.sh with the hub's own settings and --public-url https://<this machine's tailnet name>. |
sudo bash scripts/setup/tailscale.sh public-url, or dashboards.sh by hand (below) |
By hand, the URL step is dashboards.sh with every setting the hub's unit has, so that it turns nothing off: leaving out --siri or --hooks drops that gateway's port and its token's ACL. The screen shows the exact command, built from the hub's unit (/etc/systemd/system/worklane-dashboard-hub.service) with every value quoted, when it can read the unit exactly; otherwise this one, with each <...> and [...] filled in from the unit's ExecStart:
sudo bash /opt/worklane/current/scripts/setup/dashboards.sh [--machine-devices <the hub unit's --console-devices or --machine-devices, if it has one>] --client-id <its --client-id> --public-url https://<name> [--siri if it has --siri-port] [--hooks if it has --hooks-port] <each instance name in its --hub>...
The dashboard never runs any of it: the coordinator runs the machine helper, which takes exactly those four commands (two fixed words each; no auth key, flag, URL or port can be passed) and starts the step as its own unit, worklane-tailscale.service. The card follows the step from the script's progress file, /var/lib/worklane/tailscale/steps.jsonl.
The login link goes to the owner only. The script writes it to /var/lib/worklane/tailscale/login-url, mode 0400, owned by the asking instance's coordinator user, and deletes it when up ends. The dashboard serves it only to a machine owner, only while up waits, and only if it is exactly Tailscale's login page (https://login.tailscale.com/a/<id>); it shows as an "Open Tailscale's login page" button. The link is never in an event, an audit entry, the journal or the machine changes log. No auth key is used anywhere on this path.
Two things stay on other sites. MagicDNS and HTTPS certificates are turned on in Tailscale's admin console (DNS); until they are, the HTTPS step says so and links there. After the URL step, set the sign-in App's callback URL on GitHub to exactly https://<name>/auth/callback: sign-in at the new URL fails until you do. The hub restarts for that step, so a session at the old URL ends; the break-glass still works.
What it can never do:
- Turn on Tailscale Funnel, or publish anything outside your tailnet. There is no funnel command on this path,
tailscale serveis tailnet-only, and the hub itself listens on127.0.0.1. The card shows Funnel's state read-only; if Funnel is already on, the HTTPS and URL steps do nothing until an admin turns it off (tailscale funnel reset). - Choose what is forwarded or where: the serve step is one fixed command to the hub's sign-in port. It never forwards the break-glass port (4398) and never replaces something else already on 443.
- Change what
tailscale upexposes: it is run with no flags (no exit node, routes, SSH, login server or auth key). - Set the hub's URL to anything but this machine's own
*.ts.netname, which the script reads fromtailscale statuson the machine; nobody types it. - Install a package that isn't signed by the pinned key.
On a server instance, the First run page's Discord step (src/intake/discord-setup.ts; Settings links to it, and it opens again on a finished step as Change) sets up the chat bot. It is optional. Opening it, loading the bot's channels and reviewing a change write nothing; each write is an owner's, confirmed first, and audited (refusals too). Writers read where it stands. docs/intake.md section 10.1 has the script for each part.
What it can change, all as the instance's coordinator user (the user its dashboard runs as), and nothing as root:
- The token. Paste the token opens a paste box: a
paste_secretask for the credentialintake-discord(POST /api/first-run/discord/token-ask). The value goes through the setup console's secret route, from the browser straight to~/.config/<name>-secrets/intake-discord(0400, in a 0700 directory), is refused unless it is shaped like a bot token, andcredentials.yamlintake.discord_tokenis then pointed at the file through the same code ascredentials.sh <name> intake-discord(setDiscordToken,src/instance.ts). intake.yaml, in the instance home (0600): onlydiscord.enabled,guild,channels,asks_channel,ops_channel,chat_channels,dashboard_urlandsuperfan.enabled,channel,machine_channel, picked from the bot's own list. Review (POST /api/first-run/discord/intake) returns the exact diff and writes nothing; Confirm sends back that review's hash, and the server writes only if the file and the bot's list still give that very change. The result must pass the intake schema; a superfan channel is never one the bot reads.
What it can never do: read or show the token (the dashboard's process never opens the token file; the bot's application id, its invite link and its channels come from a child process, <cli> discord channels --instance <name> --json, which reads the token itself, asks only Discord's own API (its base URL is a constant: nothing can point it at another host) and prints names and ids only, GET /api/first-run/discord/channels, owners only; the child is always the installed engine's own cli.js, by absolute path: no PATH lookup, option, environment variable, config key or request changes which program runs); write any other file, or any other key of intake.yaml (who may send, people, owners and trusted stay by hand); post, read or change anything on Discord (the child process makes GET requests only); restart the instance (it shows the command); or run anything as root. A hub forwards these calls to the instance's own dashboard like every other, so the hub's user never holds the token either.
The invite link asks for exactly four permissions (View Channels, Send Messages, Create Public Threads, Send Messages in Threads; integer 309237648384) and the bot scope. Creating the bot and turning on its Message Content Intent stay in Discord's developer portal: the step links there, and says whether the intent is on once the bot's view is loaded.
The First run page's Intake sources group (its id stays discord, so setup discord still names it) holds three
steps: Discord (above), Slack and Linear (src/intake/source-setup.ts, web/src/components/SourceSetup.tsx).
The Intake page shows the three side by side (on, off, not set up, or refused), with counts only; an owner's card links
to its step. Each is optional; nothing is on until intake.yaml turns it on and the instance restarts.
- Nothing set up: the step says what the source does, how it connects (Slack: outbound over Socket Mode; Linear:
polling Linear's API; no public address either way), and offers one first action: Create the Slack app from the
manifest (Socket Mode, the
app_mentionevent, the bot scopesapp_mentions:read,chat:write,users:read,channels:read, nothing else) or Create a Linear API key (Read and Create comments, the one team). The later steps follow once it is taken. A writer gets the same words and no button. - The secrets. Slack's app-level token (
xapp-) and bot token (xoxb-), or the Linear key (lin_api_): each a paste box (apaste_secretask forintake-slack-app,intake-slack-botorintake-linear,POST /api/first-run/<slack|linear>/token-ask), shape-checked, straight to a 0400 file, andcredentials.yamlintake.slack_app_token,slack_bot_tokenorlinear_api_keypointed at it (setIntakeCredential, ascredentials.sh <name> intake-slack|intake-lineardoes). - What it sees:
GET /api/first-run/<slack|linear>/view, owners only: a child process,<cli> slack viewor<cli> linear view --instance <name> --json, run by the same runner as the Discord listing, which reads the secret itself and prints names and ids only (the workspace, its public channels, its people; or the teams, their labels and members). - The picks:
POST /api/first-run/<slack|linear>/intake: withoutconfirm, the exact diff of intake.yaml (nothing written); withconfirmand the reviewed hash, that change, 0600, renamed into place. It writes onlyslack.enabled,slack.team,slack.channels, orlinear.enabled,linear.team,linear.label, each id from the view.
What it can never do: read or show a secret (the dashboard's process never opens a secret file); write who may send
(slack.allowed_senders, linear.allowed_senders) or who is trusted (they stay in intake.yaml by hand, and the step
only counts them); write any other key or file; post or change anything on Slack or Linear (the view makes read
requests only); restart the instance; or act for a writer or a viewer (every write route is the owners', each refusal
audited).
Each agent user (each instance's agent user, which channel chat runs as too, and its eval user) has its own Claude login. The engine manages no Claude account (issue #736): it switches no login, keeps no account profiles and fails over between none. A person signs an agent user in, on the machine, as that user, through Anthropic's own flow (sudo -u wl-<name>-agent -i claude auth login), or installs a token or an API key with credentials.sh (docs/auth.md). To see each agent user's account, on the machine:
sudo bash scripts/setup/claude-account.sh status # each agent user's account, token id and expiry; or name some
What status finds is kept in /var/lib/worklane/claude-account/status.json (root's, 0644; token ids, never a token). The first-run page's "The agents' Claude login" step reads it. add-project and credentials.sh <name> claude refresh their instance's records once its token is in. status checks each agent user with claude auth status, run as that user, and with one minimal real call to the API as that user: Haiku, "Reply with the single word: ok", one turn, --max-budget-usd 0.05. claude auth status only says a credential is configured; a login it calls fine can still be refused by the API with a 401 on every run, which is what issue #463 was. A refused call isn't billed; an answered one costs about one short Haiku turn per agent user. status records it in status.json (api_ok, api_error, with token-shaped strings redacted), and exits 1 when the API refused one. The first-run page's "The agents' Claude login" step reads it as not done.
From the dashboard, the Machine page's Claude account card shows the same status. It's visible only to machine owners: the server refuses everyone else its data (GET /api/claude-account, src/claude-account.ts). Refresh is the machine request claude-account status, gated like any machine change (an owner here who is a machine owner, signed in, on an allowed device). The card names the sign-in command and has no box to paste a token into (issue #711). The route earlier versions took a pasted token on (POST /api/claude-account/token) answers 410 Gone, its body never read; the machine helper refuses claude-account switch, and the script refuses it and --token-from. A token file an earlier version stored for it is removed, unread, by the next run of the script.
What account profiles, failover and the switch left (issue #736). On a machine an older engine ran them on, /var/lib/worklane/claude-account/ may still hold profiles.json, profile-ops.jsonl, switch.jsonl and paused, and each agent user's home a ~/.claude-profiles/. Nothing uses them: each run is on its agent user's own login, and every run's user's whole ~/.claude-profiles stays on its sandbox and Read deny list. An instance whose runs were on another profile (profiles.json's active) doesn't start, and says why; so does every instance while profiles.json can't be read. The engine never moves a login between profiles or users. worklane doctor and the card name what is left and the root step that removes it:
sudo bash scripts/setup/claude-account.sh uninstall # each user's ~/.claude-profiles (as that user), then root's files
It keeps status.json and every user's own login. If a user's dir can't be removed, it removes nothing else and exits 1. Then sign the agent user in yourself, if it isn't signed in. An instance a switch left stopped (in paused) stays stopped until you start it.
What each role learns of the account (issue #567):
- A machine owner gets the card: each account, its email and plan as Claude reports them. On the Health page (as an owner or writer here), a
claude_authincident's detail names the refused login and quotes Claude's words, and anauth_mismatchone quotes whatclaude auth statusprinted. That is the incident's owner-only detail (ownerDetailinsrc/watchdog/incidents.ts), shown by the same check as the card (mayViewClaudeAccount). - A writer (or an owner here who isn't a machine owner) gets the card refused. The sign-in banner still names a refused login as Claude names it, with Claude's words, because writers fix it. The Health page's incident detail doesn't: they get the detail the Discord ops channel is posted, which names no account (#680). Their state carries whether an account is set.
- A viewer learns only whether an account is set (
claudeAccount.setin the state: true, false, or null when the machine hasn't checked yet). No route sends a viewer the email, org, plan or a profile name:- Their sign-in banner says a login was refused and since when, without the account or Claude's words.
- The account's events (
claude_auth.*, and an older engine'saccount.switchedandaccount.failover) aren't on a viewer's allowlist.
- Someone with no role, or no key, gets nothing: every route answers 403 or 401.
A refused login's hold reads "Claude sign-in failed: no new runs until a call on the refused login succeeds", for everyone. It is the reason shown in the state, Activity, the Board and the ignored-issues list, and the one in the report posted on the repo. A hold an older engine recorded named the account. Every reader of a hold's reason goes through one helper (holdReason in src/governor-holds.ts), which shows that older hold in the same words.
No sign-in through the dashboard (issue #711). The sign-in relayed through a dashboard (claude-account login, its one-time code pasted back) is gone: the script has no such command, the machine helper refuses it, and POST /api/claude-account/login-code answers 410 Gone. So does POST /api/claude-account/profile-add; POST /api/machine/claude-profile (an older page's profile and failover steps) answers 404 (#736).
claude auth status only says a credential is configured, not that Anthropic's API accepts it. A login can look signed in while every request comes back 401. So the runner reads Claude's own answer. A run that ends on an API authentication failure (HTTP 401 or 403, authentication_error, an invalid or expired API key or OAuth token, in claude's result or its stderr) ends auth_failed, not failed, with Claude's message (redacted) as its detail (src/claude-auth-failure.ts). A usage limit stays rate_limited, a token refresh that failed stays a transient error, and any other failure stays failed: none of them pauses anything.
One auth_failed run holds that login (the agent user's, or the eval user's: each login is held on its own):
- A red incident ("Claude sign-in is failing"). Its posted detail says which lane's login was refused, on which run and since when, and sends you to the Machine page. That detail is what the Discord ops channel gets and what a writer (or an owner here who isn't a machine owner) sees on the Health page; it never names the account or quotes Claude's message (#680). A machine owner's Health page shows the owner-only detail instead: the login as Claude names it and Claude's message. The post never reads that one. It is not bound to the last hour: it stays open until a call on that login succeeds. A success on another login, or another instance's, doesn't close it. (A run stopped because the login's auth method isn't the runtime's,
auth_mismatch, raises an incident of the same kind under the instance's own key. Its posted detail names only the auth method, when it is one the runner knows, never whatclaude auth statusprinted; that is in the machine owners' detail.) - No new Claude run starts on that login: dispatch, the chats, research, fix runs, evals. Dispatch says why once (its hold reason on the dashboard, which names no account). The ops channel gets one notice: the instance, the lane and a link to the Machine page, never the account's email, org or Claude's words (
OpsHoldinsrc/claude-auth-failure.ts). Each page shows a red banner with the login and Claude's words (a viewer's names neither: see "What each role learns of the account"). The task that hit it is requeued, with no attempt counted. The channel chat answers "Claude sign-in failed on this machine; the owner has been told." instead of its generic failure text. - The way out. Since no run may start, no run can succeed to end the hold. So the coordinator checks the login itself, with one minimal real call as the same user and login: Haiku, "Reply with the single word: ok", one turn,
--max-budget-usd 0.05. It does this every 10 minutes at most, and at once when an owner presses Re-check sign-in on the banner (POST /api/claude-auth/recheck, owners only, one a minute at most). A check that's answered ends the hold: the incident resolves, the ops channel says so once, and runs start again on their own.
What the check costs. A refused call (401/403) isn't billed, so checking a login that's still broken costs nothing, however long it stays broken. The one check that's answered costs about one short Haiku turn with Claude Code's system prompt, about $0.02 to $0.03 on an API key, or a sliver of a subscription's usage. It's capped at $0.05 and counted in the daily budget (run.cost, role login-check). Then the hold ends and the checks stop. The first-run page's "Signed in to Claude" step makes the same call to prove the login (kept six hours once it is answered, ten minutes when it's refused; Re-check calls again).
A run that failed keeps its why: the run's record (state/runs/<id>.json, field detail, redacted, at most 2,000 characters) and its run.finished event carry Claude's error text or its stderr's tail, and the run's page shows it under Why it ended.
The home page (/, g b) shows every task in flight, one column per stage: Planning (ready, claimed, a plan or research run), Building, Verifying (the coordinator's checks and the evaluator; in PR mode, a PR whose checks are running or being fixed), Waiting on you (the holds only the owner ends, as on the Waiting page: a decision, a held plan, a PR left to you, a CI fix that gave up, a block, a failed landing) and Landing (queued to land, or a PR the merge rules are merging). On a hub it shows every instance you have a role on, with a filter to pick one; an instance's own dashboard shows its own. It follows each instance's live stream.
Each card is built by the instance's own server (GET /api/board, src/board.ts) from what the coordinator already recorded: the instance and issue (and PR) numbers, the title, diff +/− (the PR's as the watch last read it, pr.stats, else the last proposed change), the PR's comment count, how long since the task started, its checks (failing, pending or passing; click for each), and, while an agent runs, its memory, CPU time and processes (its process group, read from /proc on Linux; not measured elsewhere). Nothing is fetched from GitHub per card.
The actions on a card follow your role there, and each is refused again by the route it uses:
| viewer | writer | owner | |
|---|---|---|---|
| Watch a running agent (the live feed) | – (403) | yes | yes |
| Steer (a console message, held until the turn ends) | – (403) | yes | yes |
| Answer the open decision | – | where the decision rules allow (not plans) | yes |
| Approve a high-risk landing, or Merge a high-risk PR left to you on GitHub | – | – (403) | yes |
A viewer's board leaves out everything about runs and agents (the run, its resource use, the diff, comments and checks): the card is an allowlist of fields (VIEWER_CARD_FIELDS in src/board.ts), so a field added later stays hidden from viewers until someone puts it there. A steer message is the one console action writers have: the dashboard (console.ts) and the coordinator apply the same rule (mayRequest), and stopping a run, asks and root steps stay the owner's. Every answer and message is recorded in the audit under its sender's login and role.
Extend (sidebar, under Agents; g x; ⌘K "Plugins") is where an instance's agents get more than the repo gives them. It has one tab now, Plugins. Automations takes a second tab when it lands, so there is still one sidebar entry. Plugins use Claude Code's own plugin format; MCP servers come from inside an approved plugin or from a pinned npm package.
Who sees what
- Owners see and change everything.
- Writers see a read-only list: each plugin or server, on or off, what it can reach, and its roles. They also see how many approvals are waiting. They get no previews, digests, diffs or audit, and no buttons; a line says the owners add and change plugins.
- Viewers aren't offered the page.
GET /api/pluginsrefuses them.
Adding and updating (owners)
-
Add a plugin asks the machine to fetch a preview of one of these:
- a plugin, by its git URL and exact commit, from a host on root's list (
plugins.sh hosts;github.comby default); - an MCP server, by its npm package and exact version.
Nothing in a preview runs, and nothing is approved by fetching.
- a plugin, by its git URL and exact commit, from a host on root's list (
-
The preview shows what the thing contains:
- its refusals: hooks, LSP servers, monitors,
allowed-tools, inline shell, launchers that fetch code, and anything not reviewed; - its notes;
- for an MCP server, its tools and descriptions exactly as the model will read them (invisible characters shown as ⟨U+…⟩, never rendered as HTML).
- its refusals: hooks, LSP servers, monitors,
-
Review the approval shows the record (source, files hash, roles, and an MCP server's hosts and secrets' names), its digest, and for an update what it replaces. Confirm stages it.
-
A staged approval is trusted for nothing. The page shows the one command an admin runs as root on the machine:
sudo bash /opt/worklane/current/scripts/setup/plugins.sh confirm <instance> <id> sha256:<digest>. It prints the record and asks for the id to be typed back.
Changing what is approved (owners, each Review then Confirm)
- Disable and Enable take effect from the next run (
plugins-off.yamlin the instance's home; it can only take away). - Remove asks the helper to delete the entry.
- Allowlist its MCP server previews a plugin's server.
- Take its snapshot with its secrets is the second approval, for a server that can't list its tools without them.
Blocked servers. Servers the broker blocked because their tools changed after approval are listed with what changed.
Audit. Every stage, change and refusal on the page is recorded, as is every root confirm.
Machine health moved off Overview to its own page (/machine, g m): the agent slots in use (machine-wide) and who holds each, memory, swap, load, disk, the harness's services, the off-machine backup, check times, usage per day, the merge flow, the instances side by side on a hub, and the machine-wide settings (the slot cap, engine updates) with their history, which moved there from Settings. Overview keeps a one-line summary with a link.
The Settings page's Model routing panel (part of GET /api/settings, owners and writers) lists every task type in agents.yaml routing (src/routing.ts): the model or value in effect and where it came from: the default in code, a role's model that stands in for it, the agents.yaml key (an older model_routing or eval_routing key is flagged with its new name), or the instance's policy.yaml. It is read-only: its "Change it" snippet is the routing: block to edit in agents.yaml through a pull request.
At the top of the machine settings, Updater setup sets the engine updater up from the page (the first-run page's "Engine updater set up" step opens it). It has three steps, and nothing changes until you review one and confirm it. Skip any of them, or all of them, and the machine stays as it is.
- The updater. Installs
/usr/local/libexec/worklane-updateand its 10-minute timer, with updates off. An existingupdates.jsonis kept. - Release signers. Lists the keys in
/etc/worklane/allowed_signerswith their fingerprints. On the release channel the updater installs only a release tag whose signature verifies against one of them. - Channel. Shows what the updater follows. A new install follows
release(signed release tags). If the machine is onmain, the page offers Follow releases; it never offersmain.
The on/off switch is the Engine updates row in the table below, as before.
Adding a signer takes two people's worth of access: the dashboard, then a root shell. Those keys are the root of trust for engine updates, so a dashboard session alone can't add one, whoever holds it.
-
On the page, type the signer's principal (its email) and paste the public key (
ssh-ed25519 AAAA..., the line of the.pubfile). Review to stage shows the exact principal, the whole key and its fingerprint; Stage this signer records it in/var/lib/worklane/pending-signers.json. Staging changes nothing inallowed_signers: a staged key is trusted for nothing. -
The page then shows the staged signer with the exact command to run as root on the machine:
sudo bash /opt/worklane/current/scripts/setup/updates.sh --confirm-signer SHA256:<the fingerprint the page shows>
Check that fingerprint against your own key first (
ssh-keygen -lf <key>.pubprints the same). The command applies only a staged signer with exactly that fingerprint. It prints who staged it (the coordinator user, and so the instance) with its principal and key, then asks you to type the principal: anything else, or nothing, adds nothing. Then it appends one line (the signers already there stay as they are), and clears it from the pending list. A fingerprint that matches nothing staged adds nothing. -
A staged signer waits 24 hours, then is dropped; Discard takes it out sooner. At most 4 wait at once.
Staging a signer raises its own incident ("A release signer was staged for engine updates"), with the principal, the fingerprint, and the coordinator user and instance that staged it, so an owner hears of a request before anyone is asked to confirm it. Whenever a signer is added, by this route or by updates.sh --signer, the watchdog raises an incident ("A release signer was added for engine updates") with the principal and the key's fingerprint: it shows on the Health page and is posted once to the ops channel. It closes by itself a day later. If you didn't add it, turn engine updates off, remove its line at a root shell, and find out who did.
The signers file is watched. On every run (every 10 minutes, whether updates are on or off) the updater hashes the signers file and compares it with the hash it last recorded in /var/lib/worklane/signers-state.json. updates.sh --signer and --confirm-signer record the new hash as they append, so their change is expected. Any other change (a hand edit, another tool, a file that was tampered with or deleted) is reported once: a signers_changed line in the update log (the Machine page's Engine updates table), a line in the unit's journal (journalctl -u worklane-update.service), and a red incident, "The release signers file changed outside updates.sh", each with the old and new hash, the number of signer lines before and after, and the key fingerprints added and removed. It needs no coordinator to notice or record; the post to the ops channel is made by a coordinator's watchdog, like every incident.
While such a change is unacknowledged, no release is installed: the release channel is held (the main channel checks no tag signature, so it is not, with one exception: a release install's first step onto main reads its release tag's signature as proof, and is refused while the hold stands). Look at the file, put it right if the change isn't yours, then acknowledge it as root on the machine:
sudo bash /opt/worklane/current/scripts/setup/updates.sh --acknowledge-signersIt prints the file's hash and every key fingerprint it now holds, records them, and lifts the hold; the updater's next run closes the incident. The first run on a machine with nothing recorded just records the file, with no alert.
What the page can change, and as whom: each confirmed step is one command of the root-owned machine helper (install-updater, set-update-channel release, stage-update-signer or discard-update-signer <principal> ssh-ed25519 <public key>), recorded by this instance's dashboard and run by its coordinator through sudo -n. Only an owner of the instance who is also a machine owner, on an allowed device, can confirm one. The dashboard itself never runs sudo and never writes these files.
Holds. A commit the updater rolled back is held: it is not tried again until a newer commit lands, or until an admin releases it at a root shell with sudo bash /opt/worklane/current/scripts/setup/updates.sh --release-hold, which shows what was held and why, moves the hold aside and logs it. Nothing on this page releases a hold: neither the channel nor the on/off switch, nor anything else a coordinator can run through the machine helper.
What it can never do: add a signer, or remove or replace one (the helper never writes allowed_signers; only updates.sh does, as root, and only by adding a line); stage a wildcard, a list or an option such as cert-authority, or any key type but ed25519; move a machine to the main channel; point the updater at another signers file; change the required checks; or turn signature or check verification off.
The same from a terminal, as root, from the engine's checkout:
sudo bash scripts/setup/updates.sh # install (off)
sudo bash scripts/setup/updates.sh --signer <principal> ssh-ed25519 <public key> # add one release signer, directly
sudo bash scripts/setup/updates.sh --confirm-signer SHA256:<fingerprint> # add the one staged from the page
sudo bash scripts/setup/updates.sh --channel release # or: main (a root shell only)From a release install to main. A release install runs the tag's release commit, which is main's commit plus dist/ and so is never on main itself. After --channel main, the updater treats it as the main commit the release was cut from, but only once the release check proves it: the signed tag object that names the installed commit verifies against allowed_signers, names itself, and its commit has one parent, on main, and changes only dist/. From there everything is as on any main install (green required checks, the first-parent line, max_lookback, holds, rollback to the release commit). Anything unproven is refused as before: not a fast-forward of the installed engine ...; and it isn't a verified release commit: <why>. The updater must be one that knows this step (#895): once a release that has it is installed, sudo bash /opt/worklane/current/scripts/setup/updates.sh --channel main installs that release's updater and sets the channel in one step. An older updater refuses the switch every run; --channel release goes back.
Any other kind of key, or removing one, is an edit of /etc/worklane/allowed_signers by hand (one line per key: <principal> <key type> <public key>, root:root 0644). The two signer rules in the machine helper's sudoers file are written as patterns, which sudo reads as such only from 1.9.10. machine-helper.sh checks this machine's sudo: on an older one it does not install those two, says so (REFUSED: ...), and installs every other rule. Staging from the page is then refused; use --signer at a root shell.
Above the machine settings, the Updates panel (GET /api/updates, owners and writers) says where the engine stands: the version and commit installed now, the last update, any rollback or held commit with its reason (a canary that failed says so), and the canary's last result: passed or failed (or timed out), after an update or the daily one, how long it took, and a link to its PR. See canary.md for setting the canary up.
The Reports page opens with the Trust panel: this instance's DORA-style numbers, read from its event log
(trustMetrics in src/merge-metrics.ts, src/trust.ts), over the last 7 days with the 30-day value under each, and
per risk area (money path, auth, other) over 30 days. It is read-only measurement (docs/design.md, the waiver of
2026-10-13): GET /api/trust is the only route, and nothing it serves changes slots, caps or merges.
Under it, Flaky tests (owners and writers; GET /api/flaky, src/flaky.ts): each check quarantined because it
failed on the default branch and passed when re-run on the same commit, with when it was first seen, how many times
it has been seen flaky since, its issue, and its passes in a row toward release; the ones released lately; and the
flake rate, flaky re-runs over red runs, over 7 and 30 days. An owner's Clear puts a check back to blocking at
once (POST /api/flaky/clear, audited). Quarantine changes only the harness's own red/green decisions after a merge;
the updater still installs only a commit with every check green.
Under it, Learnings (owners and writers; GET /api/learnings, src/learnings.ts): the notes finished tasks left for
their failed runs (earliest failed stage, normalized cause), grouped by cause, with sightings, stages, issues and when
last seen. For a cause seen 3 times or more, an owner may edit the suggested line and Promote it
(POST /api/learnings/promote, owners only, audited): the coordinator opens a PR adding the line to AGENTS.md,
labelled review:L3, which waits for the owner. Agents never write AGENTS.md themselves.
Under it, Delegation (owners and writers; GET /api/delegation, src/delegation.ts), read-only: each review
category's record since the owner's last answer on it (PRs approved unchanged, changed, or gone wrong after: reverted,
main red, a later fix), how many waited for a person, the time to approve (median, fastest, slowest) and who approved
(owner or writer); each approver's history across categories; and each suggestion with its answer. The same evidence
goes with a suggestion to stop waiting on a category in the Inbox. Nothing loosens without the owner's answer, which
only files a review.yaml PR they review; high-risk, safety-floor and agent-instructions categories are never
suggested.
- Change failure rate: of the merges in the window, those that failed: within 7 days the default branch went red
on it or auto-merge stopped on it, a revert named it (its commit,
#numberor quoted title), or a fix PR (abugissue, or a title starting "fix") changed one of its files; or a production report was traced to it. - Escaped bugs: production reports (issues intake filed) traced to a harness merge: the latest merge, up to 30 days before the report, that changed a file the report's fix changed.
- Time to restore: the median hours from a failure showing to its fix or revert merging, or the default branch green again (or auto-merge resumed).
- Revise/reject rate: of the issues a person reviewed (a plan decided, a correction recorded, a merge), those sent back (a plan revised or rejected, a PR or landing rejected, changes requested).
- Flaky-check rate: of the checks and test tiers run on a commit, those that both passed and failed on it.
- Cost per merged PR: the runs' own cost estimates (not billed money) and tokens over the merges; runs on no issue count in the instance's total only.
- Verification time per tier: each test-run tier, the coordinator's verify checks and the evaluator's runs.
- Risk area: money path when the review level names a money path (or the issue has the
money-pathlabel), auth when it names auth or secrets, else other.
The recommendation is fixed rules (TRUST_RULES), never a model's: "Ready to raise to 3 agents" at 30 or more
merges in 30 days, a change failure rate under 5% and no escaped money-path bug; otherwise "Keep 2 agents" with
what is missing. Per area: verify more heavily at a 10% change failure rate (over 10 or more merges) or any escaped
bug; lighter only for other changes, under 2% over 20 or more merges; money path and auth are never advised lighter.
Raising the agent count stays a machine owner's action (the Machine page's slots).
Who sees what. The owner and writers see every figure. A viewer sees a figure only when the viewer-events
allowlist (src/viewer-events.ts) shows every event field it is made of, and never cost; today that is the merge
counts. The weekly digest posts the same numbers to the Discord ops channel (discord.ops_channel in
intake.yaml), once a week, when one is set.
Under Trust, the Metrics panel (GET /api/metrics, the owner's and writers'; deliveryMetrics in
src/merge-metrics.ts) shows how fast and how safely changes reach the default branch, over 7 days with the 30-day
value under each. A figure whose source has recorded nothing yet says not measured yet, and why; never a 0.
- Merges per day: merged PRs and direct lands the event log records, with the last 14 days as bars.
- Main red and time to green: from the check of the default branch after each auto-merge
(
merge.main_result): the times it went red, and the median minutes from red to its next green. - Fix-forward versus revert: revert PRs opened (on a red default branch, or for a commit the main watch couldn't
account for) and merged, and the revert rate (reverts on red over the times it went red). Fix-forward success:
fixed over every red default branch fix-forward took on, from its
fix_forward.finishedevents. - PR CI wall-clock: on each merged PR's head, from the first check run starting to the last required check
(config.yaml
required_checks; every check when none) completing. Read from GitHub's check runs once per PR (ci.pr_timing, src/ci-metrics.ts), at most one read-only call a minute. - Agent startup: seconds from a task being claimed to its agent's process starting.
- Flake rate: of the re-runs of a red default branch's failing checks, those that found a flaky check
(
flaky.rerun_result); not measured until flaky-test re-runs exist. - Slow tests: the 20 slowest tests over 7 days and every test over the budget (config.yaml
metrics.slow_test_budget_s, 30 s by default), each by the median of its runs. Durations are read from test runs' tier output (testrun.durations) and, once a day, from the GitHub Actions job logs on the newest merge commit (ci.test_durations). Only formats that name one test per duration are read (src/test-durations.ts: node:test TAP and spec, pytest--durations,go test -v, jest and vitest verbose), and only leaf tests.
The weekly digest posts these after the trust numbers, with the slow-tests list (the weekly optimization pass's targets) in its own message.
In the Ask panel, the owners and the writers attach files to a question: drop them on the panel, paste them into
the box, or use the paperclip next to the mic. Each shows as a chip (name, size, remove) and uploads at once to the
instance being asked (on a hub, one instance: not "Every instance"). A viewer never sees the paperclip, and the
server refuses a viewer every attachment route (403), downloads included. Code: src/chat-attachments.ts,
web/src/components/ChatAttachments.tsx.
- Types: images (png, jpeg, gif, webp), PDF, plain text and code (by extension, then checked to be valid UTF-8 with no NUL bytes), CSV, JSON, and Office files (docx, xlsx, pptx). The type comes from the extension, checked against the bytes (an image's or a PDF's magic number, Office's zip header); the client's Content-Type is never trusted. Anything else is refused with a reason (415), and macro-enabled Office files (docm, xlsm, pptm and the rest) always are.
- Sizes: 10 MB per file and 25 MB per question by default, at most 10 files. The per-file cap is enforced as the
body streams in (a declared length over it is refused before any of it is read; past it mid-stream, the upload
stops and the partial file is deleted); the per-question total is checked when the question is asked. Set them in
the instance settings (Settings, or
policy.yamlsettings.chat_attachments.max_file_mbandmax_message_mb). - Storage:
<state>/chat-attachments/<id>, file 0600 in a 0700 directory, under a random id (att-and 32 hex digits); the uploader's file name is kept only in the record beside it, for display and the download's name, never as a path. Never in the repo, the logs, events or URLs (the name travels in theX-Attachment-Nameheader). The audit event (chat.attachment_added) records only the id, name, type, size and sha256. Files are deleted after 30 days by default (settings.chat_attachments.retention_days), except a file a chat Project holds (kept until it is taken off the Project or the Project is deleted); the coordinator sweeps about once an hour, and removes uploads left half-written. - Secrets: a text file is scanned on upload with the engine's secret patterns and, where it is installed, gitleaks. A hit is not attached: the chip says which kinds it looks like (a GitHub token, a private key; never the value) and offers Attach anyway. The matched value is never logged, echoed or stored anywhere but the file; what the chat's model is given is redacted again. Images, PDFs and Office files aren't read by the scan (it reads text only): their chip says "Not scanned for secrets".
- Download: through the dashboard only, with the normal sign-in (fetched, never a link with a key in it). Each is
served with its allowlisted type,
X-Content-Type-Options: nosniff, a sandboxing CSP, andContent-Disposition: attachmentunless it is an image (shown inline). HTML is served as text/plain; SVG is never taken. Through a hub, every forwarded response gets the hub's owndefault-src 'none'; sandboxpolicy and nosniff, whatever the instance sent, so not even a hostile instance can serve a page the browser runs on the hub's origin. In the page, a fetched file becomes a blob: URL (the page's own origin, without the server's headers), so the page decides again: only a png, jpeg, gif or webp opens in a tab; anything else downloads as opaque bytes. - What the answer sees: nothing runs from the chat. Text goes into the chat turn's prompt between markers that carry a random nonce, redacted, framed as data from the asker and never instructions; images and PDFs are copied into the turn's bundle for the Read tool to view, and deleted from it when the turn ends (the state keeps the only copy, for its retention days); Office files are named and described, never unpacked or opened. Text the answer reads, like the question itself, is part of the chat run's own Claude session.
- Who opens one: its uploader, or someone who can see a chat Project that holds it. Anyone else is refused (403) even knowing its id, an owner included. Deleting one is the same: its uploader, or the creator of a Project that holds it; the sweeps still remove what they remove. A question, a conversation message or a Project takes only its sender's own uploads; someone else's id reads as unknown.
- API:
POST /api/chat/attachments(the body is the file;X-Attachment-Name, URI-encoded;X-Attach-Anyway: 1after a warning),GET|DELETE /api/chat/attachments/<id>(removal: who attached it, or the creator of a Project holding it; nobody else, owners included),GET /api/chat/attachments/limits, andattachments: [<id>, ...]onPOST /api/chat. Anything that refers to a file names only its id.
The owners and writers get a sidebar in the Ask panel (src/chat-conversations.ts, src/chat-routes.ts,
web/src/components/Conversations.tsx): their own multi-turn conversations, the Projects they can see, and the
quick single-shot question, unchanged. A viewer never sees it, and every route refuses them (403).
- A conversation keeps its thread: each answer is given the conversation's earlier turns, the newest that fit (at most 12 turns and 24,000 characters, each question and answer clipped to 4,000); older ones are left out, never summarised, and the prompt says how many. It is its creator's only: nobody else lists, reads, asks in, renames or deletes it, the instance's owners included (to anyone else it reads as not there, 404). An untitled one takes its first question's opening words. A message can carry chat attachments, its sender's own, within the same caps.
- A Project has a name, custom instructions and shared files (chat attachments, at most 10, within the per-question size cap). It is its creator's until an owner shares it, with all the instance's owners and writers: they see it read-only and start their own (still private) conversations in it. Only its creator edits or deletes it; a writer can't share one, nor edit an owner's shared one. A shared Project stays shared only while its creator is still an owner. Its files stay past the attachment retention days while it holds them; one taken off, or the Project deleted, is deleted at once unless another Project still holds it.
- What the answer sees: the earlier turns and the Project's instructions go into the turn's prompt as data, each block between markers carrying a random nonce (as attachments are framed), never into the system prompt: they steer tone, focus and format only, and the prompt says nothing in them overrides the chat's rules. The message's files and the Project's go through the attachment framing above. Same tools (Read, Glob, Grep), same read-only rule for anyone but the instance's default owner. A conversation turn neither reads nor writes the single-shot chat's summary and history, and its run record and bundle are removed when the turn ends.
- Nothing runs from it: an answer's issue draft and proposals are the single-shot chat's own: a draft is filed
only through its confirm, by an owner, once (
POST .../turns/<turn>/file-issue); each proposal opens the flow that already confirms that change. - Kept: in the instance's state dir (
chat/conversations/<id>/,chat/projects/<id>.json; 0600 files, redacted), never the repo, logs or events (the audit carries ids only). No retention limit: a conversation or Project lasts until its creator deletes it, and delete is a hard delete (its turns, any question still waiting, its filed-draft records and its messages' files). When someone loses their role here, all of theirs is out of reach at once (every route asks their role; their shared Projects disappear from others' view) and is deleted 30 days later by the coordinator's hourly sweep; a role given back before then keeps it. The sweep does nothing while the instance's default owner doesn't read as an owner (roles can't be trusted then). A turn cut off by a restart is refused once, never run again. - API:
GET|POST /api/chat/conversations(?cursor=&limit=&project=),GET|PATCH|DELETE /api/chat/conversations/<id>,POST /api/chat/conversations/<id>/messages({ text, attachments?, key? }; the text only ever in the body),POST /api/chat/conversations/<id>/turns/<turn>/file-issue,GET|POST /api/chat/projects,GET|PATCH|DELETE /api/chat/projects/<id>.
Under the Ask panel's question box, the owners and the writers pick the model and effort one answer runs on. A
viewer never sees the picker, and the server refuses a viewer every chat POST (403). Code: src/chat-pick.ts
(chatRunOptions), web/src/components/ChatPicker.tsx.
- Models: only the Anthropic models agents.yaml
routingnames (every key in effect) androles.chief_of_staff.model. A pick can only narrow to one of them; it never adds a model or a provider. Open models (local,hosted:<name>,cli:<agent>) never run chat. The owner may pick any of them; a writer only one at or below the default's price tier (haiku < sonnet < opus); a model of no known tier is refused for a writer. The instance'smodel_capstill caps whatever is picked. - Default:
routing.chat, elseroles.chief_of_staff.model, else sonnet. Leaving the picker on "default" runs on it. - Effort: low, medium or high, passed to claude as
--effort; "default" sets nothing (the CLI's own default). - Budget: each conversation has a cap,
roles.chief_of_staff.conversation_budget_usdin agents.yaml (default $5); it is config only, never set from the dashboard. Before each answer the coordinator checks it: at or over it, the question is refused. The answer's own cap is the least ofroles.chief_of_staff.budget_usd(default $1), what's left of the conversation's cap and what's left of the day's budget. A conversation (above) counts over its whole life, under the id the server keeps for it (never one the client sends). Quick single-shot questions share one stream per instance, counted over the machine's local day. - A refused draft: when the contract parser refuses an answer's issue draft (a part missing, say), the chat is
asked once more with the reason, inside the same answer's caps: the retry gets only the budget and turns the first
try left (none when under $0.05 is left), and both runs count as that answer's spend. A second refusal is shown as
it is ("Can't be filed as it is", with the reason); there is never a third ask. Code:
src/chat.ts(chatTurn). - Per person: anyone but an owner may spend at most
settings.chat.person_daily_usd(the instance's policy.yaml, which only the owner edits; default $10) on chat answers in a local day, across all their conversations and quick questions. Owners are exempt. The person is the signed-in login the dashboard names, never anything in the request body. The coordinator checks it before every answer, before any model call. At the cap, the question is refused with the cap and when it resets (local midnight). Each answer's cap is also at most what is left of it. - Cost: each answer shows its estimated cost, the model it ran on, and the conversation's spend against its cap.
Each answer's
run.costevent carries its conversation and its asker's login. - API:
GET /api/chat/options(what this person may pick: models, efforts, the default, the cap) andpick: { model?, effort? }onPOST /api/chatand on a conversation'sPOST …/messages. The coordinator checks the pick again before the answer runs.
In PR mode, the Pull requests page (g p) shows every PR the harness opened, from its own events:
- Auto-merge on this instance: on, off or stopped, and why. It reads the same three things the coordinator reads: the instance policy's
auto_mergekill switch (re-read each time), the repo'sreview.yamlmerge.auto, and the stop file a red main leaves after an auto-merge, with its revert PR. The operator clears that file. - Waiting for you: PRs the merge rules left to a person, and PRs whose CI fix runs gave up. Each one lists exactly why, with its checks on the current head and a link to review it.
- In progress: drafts whose checks are running, CI fix runs in flight, ready PRs. Expand one for its checks, why it isn't ready, and its fix attempts.
- Auto-merged: each PR, its merge commit, whether main stayed green, and the policy's reasons for merging it.
- PRs merged by a person or closed, and pushes the harness refused before they left (size, protected or refused paths).
The sidebar counts the PRs that need you. Through the hub, this is per instance.
The daily "waiting on you" digest (the comment on the digest issue, and the Markdown on the Waiting page) starts
with one table of what is the operator's (#1032): PR | why it's yours | labels | merge command, one row per PR or
hold. It is read off the event log as it stands when the digest is made, the same read as the rest of the digest, so
an answer about what needs you comes from it, not from an agent's memory. src/inbox.ts builds it; it makes no
network call of its own.
- A PR is a row when its head's review level is L3, the merge policy left it to you (its recorded verdict,
merge.decided), a check failed or was cancelled at its latest status, a CI fix gave up, a required check holds it, or it has been a draft for more than 2 hours (with why: why it was turned back into a draft, else its latest status's reasons, else "no status recorded"). - Other rows: decisions and held plans (with the
decidecommand), blocked tasks and failed landings, and the stops: emergency stop, auto-merge stopped, unreported checks on the default branch, a failed deploy, a held engine update. - The merge command (
gh pr merge <n> --merge --match-head-commit <head>) is shown only for a PR that is ready, green, not a draft, and held by nothing but your say (L3 or left to you). - A task that waits only on another (a "Depends on" or a hotspot hold) folds into one line, followed down to its
root with the root's reason:
#4 -> #3 -> #2: root #2: <why #2 waits>. A cycle says so. - UNREADABLE: when a source can't be read (an event the inbox couldn't read, say), the table says UNREADABLE for it, and never says nothing waits.
- Not yet: labels are the issue's labels as last seen, not the PR's live ones; a stacked PR's base chain on GitHub isn't followed (the chain above is the tasks' dependencies); there's no command of its own yet.
Light mode uses the warm light palette: every text colour in it reaches WCAG AA (4.5:1) on every light background it can sit on, tints over hover backgrounds and placeholder text included, and a control's edge and the focus ring reach 3:1 on every light background, hover ones included (test/theme-contrast.test.ts, which reads the text colours the pages use). The accent is never a text colour (accent-coloured text is text-blue-ink), a field or button draws its own edge with border-line-strong (border-line is for dividers), a focus or selection ring uses the ring token (var(--ring), ring-ring), never the accent, and a progress bar or chart mark draws in the ring token too (bg-ring), so it reaches 3:1 on its track; the same test refuses each, and any colour class that names no theme colour (a typo draws nothing). Dark mode uses Beautiful UI's palette. The pages use Beautiful UI's patterns (MIT; see THIRD_PARTY_NOTICES.md), adapted to the dashboard's data:
- decisions are approval cards
- agent runs are task rows, and a run's steps are tool chips
- checks are records tables
- pages and the hub's instances are sidebar rows
- the headline figures are insight cards
The shared pieces live in web/src/components/patterns.tsx and ui.tsx. test/web-render.test.ts renders every page from the demo project's state and checks that its data is shown and that decisions stay answerable only where the server allows.
To see every page, light and dark, against a freshly seeded demo (needs Chrome or Chromium):
node scripts/dev/screenshots.mjs --out shots # this checkout, built
node scripts/dev/compare-screenshots.mjs --out shots # origin/main vs the current branch: shots/before, shots/afterThe engine install (scripts/setup/engine.sh) builds the UI into dist/web. In a source checkout, run npm run build:web. Without it the page says the UI isn't built.