DocsThe desk UI

The desk UI

The UI of desk (#72, #73) is a Next.js app in ui/. A person signs in with better-auth through the fleet preset of @munhq/auth, and the pages read the desk server over HTTP with the service token of the UI. The browser holds a session cookie of the UI and never a token of the server.

Connect an AI client (the MCP endpoint)

A person needs only an account: an admin of an org adds the email on /orgs/<org>, and the person sets a password from the one-time link. The AI client then connects once:

Client Steps
Claude Code 1. claude mcp add --transport http --scope user desk-server https://desk.munhq.com/mcp. 2. In Claude Code, /mcp, pick desk-server, Authenticate. 3. Sign in to desk in the browser and click Allow.
claude.ai 1. Settings → Connectors → Add custom connector. 2. Name desk-server, URL https://desk.munhq.com/mcp, Connect. 3. Sign in to desk and click Allow.

The client registers itself (RFC 7591), signs in with OAuth and PKCE, and keeps a refresh token. Each call runs as the person, with the rights of their org role.

Pages

Page What it shows
/ The public landing page: what desk is, the four steps, the six profiles, and one example review with a finding to accept or reject. A signed-in person goes on to /workspace.
/docs, /docs/<slug> The docs, served by the UI (#144): the README is /docs and each page of docs/ is /docs/<name>. Next renders them at build time from the markdown (ui/src/lib/docs.mjs, marked), so the pages are static and need no service. A relative link between the docs pages becomes a link of the site.
/login The sign-in. There is no sign-up: an admin of an org makes each account. ?next=<path> goes to that path of the site after the sign-in.
/orgs, /orgs/<org> The orgs of the person and the members of one org (#130). An admin of the org adds a person by email and role, and removes a member. A new email gets an account with no password and a one-time link to /invite, which works for 7 days. The page shows the link; with SMTP set, the mail sends it too.
/invite?token=<token> The person sets the first password from the link of an invite. The link works once.
/mcp The MCP endpoint (#133), on mcpHandler of @munhq/mcp-kit. An AI client adds https://desk.munhq.com/mcp, signs in once with OAuth in the browser, and calls the 28 tools of src/lib/mcp-tools.ts (#141, munhq/desk#140 for the CLI): the repositories, the files, the search, the blame, the diff, the landings, desk_land_change, the proposals, the issues (list, read, create, comment, claim, release, close), the decisions, the releases and the runs of a landing. at reads a file, a folder, a search or a blame at main or at a change. Each call runs on the server as the person who signed in, with x-desk-person, so it has the rights of their org role. A command stays in the CLI when it needs a folder on disk (checkout, status, change, sync, workspace; desk_land_change carries a change and its landing), a local rule or config (check, template, audit, preflight, review, profiles, requirements, packs, doctor), an admin right (repo create/grant/import/move, org, key), a blob upload (release create), or the closecheck of the CLI (issue edit, questions, done, validate; a server route for those is the next change).
/.well-known/* The discovery documents of the MCP endpoint (wellKnownHandler).
/consent The consent page of a connector: the person allows or denies the AI client (answerConsent). A sign-in with a pending authorization goes on to the consent by itself.
/device?user_code=<code> The device login of desk login: the signed-in person approves or denies the code. POST /api/cli/token then gives the CLI a token of the desk server for that person (docs/server.md).
/workspace The workspace (#114): the top bar with the project picker and the tabs Overview, Decisions, Runs and Config. The query holds the state: `?repos=<owner/repo>,<owner/repo>&tab=overview
/r/<owner>/<repo> The files on main and the latest landings.
/r/<owner>/<repo>/tree/<path> A folder, and the landings that changed it.
/r/<owner>/<repo>/blob/<path> A file with line numbers, its blame (?blame=1), and the landings that changed it.
/r/<owner>/<repo>/search?q= The lines of the text files that hold the text.
/r/<owner>/<repo>/issues The issues, open, closed or all, and each issue with its comments.
/r/<owner>/<repo>/issues/<n> One issue with its comments.
/r/<owner>/<repo>/proposals The open proposals, and the newest landed ones (#72).
/r/<owner>/<repo>/proposals/<n> One proposal: its body, its state and head, its checks, the comments of its reviews on their file and line, and its diff against the newest change of main that its head holds.
/r/<owner>/<repo>/runs The runs, newest first, 50 on a page (?before=<id> for older ones): the kind, the name, the status, the time and the age (#72).
/r/<owner>/<repo>/runs/<id> One run: its command, its outcome (exit code or error), its time, who asked for it, its tree or its image digest, and its log.
/r/<owner>/<repo>/changes/<id> One change, from the latest landings of /r/<owner>/<repo>: its message, author and time, and its diff against its first parent (#73).

The workspace

The page /workspace reads the server on the side of the UI, with the service token, and gives the data to the client components. Each tab reads the data that it shows, a few repositories at a time; a write calls the server, then the page reads it again.

View Data Server routes
Project picker The repositories where desk-ui has a right. A filter, "All N shown", "Clear", "Show N" and "Show all N" choose several at once; the URL names them. GET /v1/repos
Overview A board that pans and zooms (drag, Ctrl and the wheel, two fingers, +, -, 0, the arrows). Each repository is a lane, each goal a frame: an open epic with the issues that its body and comments link, or the open issues that no epic links. A step is a card in a column after the steps that it waits for ("Blocked by: #n"). A card is red when a question waits for the owner or its question section cannot be read. A check of main that failed is a red card at the start of the lane. Priority is a word: low, medium, high or critical. A click on a frame title zooms to that goal; "All goals" shows every goal. GET /v1/decisions/api/overview, GET /v1/decisions/api/state, GET /v1/runs/<repo>/checks
Side panel Next to the board or the list. For a decision: each question with the map now, the map after the picked options, and for each option For, Against, Business, its own map, the work (files checked on main, infra, downtime, tests, locks) and the short route (what it leaves, and when to decide again). The plan of the epic: the issues that the decision holds up and the steps that the picked option adds; the board shows those steps as dashed green cards. "Add your own option" writes an option into the issue. "Enter the work" writes the work of an option. "Send the decision" writes the decision as a comment, with the signed-in person. For a step: its state, what it waits for and its proposals. POST /v1/decisions/api/answer, /option, /work (through ui/src/app/api/[call]/route.ts)
Decisions The questions that wait, the sections that cannot be read, and the decisions. A card that waits opens a wide split pane: the questions on the left, and on the right the roadmap, the tasks and the cost of the picks (docs/decisions.md). Each side scrolls on its own, so the effect of a pick stays in view. GET /v1/decisions/api/state, POST /v1/decisions/api/plan
Issues The issues of the chosen repositories in one list, open, closed or all: the repository, the number, the title, the labels, the state, the author and the day. A text filter reads the number, the title, the labels and the author. A repository that the page cannot read is an error of its own, and the other repositories still show. POST /v1/forge/<repo>/listIssues
Runs What runs now across the repositories, and the check, the images and the deploys of the newest changes; the promotion of an image that waits for a check shows with the images, named promote <name> (12 of each repository, 60 on the page), grouped by the issue that each change is for ("By time" lists them flat; the choice stays in the browser). A group header links to the trace of its issue. A change that repeats the code of an earlier one says so. A step that ran again shows its newest run with its number of runs. A change with a failed step is red; "Only the failed" keeps those. The page reads GET /api/pipelines every 5 seconds while it is in view. GET /v1/code/<repo>/log, GET /v1/runs/<repo>/landings/<seq>, GET /v1/runs/<repo>/runs
Trace /r/<owner>/<repo>/issues/<n>/trace, the second tab of an issue. From the top: the decision (the answers, the design now and after, the plan that the picks made, the log of the issue), the work (the task issues that the decision made, with their changes), and the changes that landed for the issue or its tasks, each with its check, images (and the promotions of the images) and deploys. Each change has a closed row "Code changes". The first click reads the diff of the change against its first parent (GET /api/changes/diff?repo=&id=, at most 200,000 characters; the files that do not fit are counted and the page of the change has all of them), then shows the files with their added and removed lines and the diff of each file. A closed row reads nothing. The page reads itself again every 6 seconds while a run is active. GET /v1/decisions/api/trace, GET /v1/code/<repo>/log?intent=, GET /v1/code/<repo>/diff/<base>/<head>, GET /v1/runs/<repo>/landings/<seq>
Config For each repository: the rights of each identity (read, write, merge, run, decide, and the paths), the checks after a landing, the images, the review profiles and rules, the agents with their tools, the skills, and the MCP servers (the tools of desk, and .mcp.json, with no arguments or environment). GET /v1/config/<repo> (src/server/config-http.ts)

The choices that the owner picked and did not send stay in the browser (localStorage, key desk-picks), for each question set by its hash. A changed question set drops them.

The decision diagrams use the format of docs/decisions.md. ui/src/lib/flow.ts lays them out with the rules of web/decisions.html, and ui/src/lib/board.ts lays out the board.

Tests

  • npm test in ui/: the auth options, the layout of the roadmap, the line counts of a diff, the helpers of the runs, the docs pages and their links (test/docs.test.mjs), and a guard (test/client-boundary.test.mjs) that fails when a server file imports a function (not a component or a type) from a "use client" module; Next stops such a page at run time. The image build runs npm test and next build, which type checks the app.
  • npm run e2e in ui/ (e2e/run.sh): Postgres and NATS in Docker, then e2e/setup.mjs starts a desk server from dist/ (run npm run build at the root first), imports two repositories from git, writes issues with a question and its diagrams, fails a check of main, makes a user and starts the UI. e2e/views.spec.mjs checks the sign-in, the picker, the board, the zoom, the side panel with its maps, an own option, a decision written and read back, the config, a proposal with its check, review comment and diff, the runs with a failed run and its log, and a landing with its diff. The new views share one sign-in, because each sign-in counts against the rate limit of better-auth. Set PLAYWRIGHT_BROWSERS_PATH when the browsers of Playwright are in another folder.

Environment

Variable Use
DATABASE_URL, DATABASE_URL_DIRECT The database of desk; better-auth keeps its tables there. The migration uses the direct URL.
BETTER_AUTH_SECRET, BETTER_AUTH_URL better-auth; the secret is Vault managed/desk/app.
DESK_API_URL The desk server, http://desk.desk.svc.cluster.local:8080 on hotmun.
DESK_API_TOKEN The service token of the UI, Vault managed/desk/ui. The server keeps the identity desk-ui on it.

The decisions page

The decisions have one place, the Decisions tab (/workspace?tab=decisions). /decisions answers 308 to it, and the header link opens it. The page of the server at /v1/decisions/page (web/decisions.html) has no link in the UI. The local page of desk decisions redirects / to the tab when the policy has a server.

Operations

  • node scripts/migrate.mjs makes the tables of better-auth. The pod runs it before the app starts.
  • DESK_USER_EMAIL=... DESK_USER_PASSWORD=... node scripts/create-user.mjs makes an account. A second run with the same email changes nothing. On hotmun the values go in with kubectl exec -i from sealkeep run, so they are in no shell history.
  • The UI reads only the repositories where the identity desk-ui has a right: node dist/server/admin.js grant desk-ui <owner/repo> read on the server.

Checked on 2026-10-15 with the image on a local Postgres 18.4 and the desk server:

  • The migration made the four tables.
  • create-user made the account of the owner.
  • The sign-in answered 200, a wrong password 401, and a sign-up 400.
  • A page with no session redirected to /login.
  • The repository, folder, file, blame, search and issues pages answered 200.