One page of your team's open GitLab merge requests that are ready for review, so you can drop a single link in Slack instead of pasting MR URLs. Terminal styled in tokyo night, server-rendered with a small React client, and zero database.
Every teammate gets a deterministic pixel-sprite avatar derived from their username, so there is no image hosting anywhere in the stack.
- What it shows
- Features
- Requirements
- Installation
- Quickstart
- Usage
- Configuration
- State
- Slack
- Agent actions
- Peer boards
- Deployment
- Development
- Part of mattstack
- Contributing
- License
Each row carries a status dot (hover it for the full blocker list), the title, the branch, one prioritized review-state phrase, the diff size, the age, and a Linear ticket link when the branch or title carries a ticket id.
The status phrase is the human review axis only, in this order:
changes requested > approved > N comments > comments resolved >
n/m approved > needs review
Mechanical blockers (merge conflicts, red CI) are not folded into it. They render as their own flag chips above the title, so the phrase always says where the MR actually sits in review.
There is no screenshot in this README. The ones taken so far were captured
against a real team's merge requests, branch names, and reviewer identities. To
see the layout yourself without wiring up a real GitLab project, copy
config.team.example.json to config.json. It is a filled-in demo roster
(ada, grace, linus), each with their own deterministic avatar.
- One shareable page. Current member, tab, grouping, and sort all live in the URL, and are remembered across visits.
- Rows or grid, light, dark, or system theme.
- Group and sort. Group by age, author, status, or my reviews; sort by oldest or by progress.
- Live. The page polls every 60 seconds and also holds a server-sent-events channel, so a status change pushed by an agent or a peer lands right away.
- Slack, by convention not by workflow. Post a review request, then read and set the three review reactions straight from a row.
- Agent actions. Right-click an MR to hand it to a Claude Code agent in a herdr pane: review it, respond to feedback on your own, or call the doctor on red CI and merge conflicts.
- Tabs. Beyond the author roster, a tab can source the approval queue for a named codeowners section.
- Peer boards. Optionally, teammates' boards can ask each other for reviews and re-reviews through a small relay.
- Bun.
- An rt daemon with the board's projects registered and the
project-mrsgrant. This is where MR data comes from: the board reads one socket call per project, and makes no forge traffic of its own. See rt. - Optionally a GitLab personal access token (
api) for member display names and MR notes, a Slack user token for the Slack actions, and herdr for the agent actions.
git clone https://github.com/m4ttstack/mattstack.git
cd mattstack # the workspace root, not this app's own dir
bun install # workspace install: packages/*, apps/*
cd apps/boardcp config.example.json config.json # fill in gitlabHost, projects, rtRepos, members
bun run setup # prompts for tokens and your defaults
bun run serve # http://localhost:7930config.json carries the team-shared fields (gitlabHost, projects,
rtRepos, members, title, and so on). Copy it once per team, or point
everyone at a shared mattstack team settings store instead.
bun run setup only handles what is yours, and is idempotent: re-run it any
time to rotate a token or change your default view. It prompts for:
- A GitLab personal access token (
apiscope, so reviews can post MR comments), created at<your-gitlab>/-/user_settings/personal_access_tokens. - Your GitLab username, used as the board's default view.
- A path to your local repo checkout (optional), which enables the right-click agent actions. Leave it blank to skip.
- Slack (optional), opening a browser to authorize a Slack app. Each teammate mints their own user token this way, so reactions and messages appear as them. See Slack for the one-time app creation.
- A peer-board invite (optional). See peer boards.
Setup also symlinks the wrapper skills in skills/ into ~/.claude/skills/,
so the panes the board launches can invoke them. skills/ is generated from
skills-src/ by bun run skills:expand:board at the repo root.
To configure by hand instead, put GITLAB_TOKEN=... and optionally
SLACK_TOKEN=... in .env.
The board lists open MRs authored by any configured member in one of your
projects. Your own drafts are shown; nobody else's are.
A left sidebar switches between All and a single member. Everything else is in the header: group, sort, rows or grid, theme, a refresh button, and a Slack filter that narrows the board to MRs whose review request has actually been posted.
A plain click opens the MR in a new browser tab. Right-click any row for its action menu instead: open in GitLab, copy for Slack, mark or unmark a Slack reaction, flip your own MR between draft and ready, and (from a local hostname) the agent actions.
config.json in the repo root is gitignored and drives everything, with the
mattstack settings stores layered over it per key. The essentials:
| field | meaning |
|---|---|
gitlabHost |
your GitLab instance, e.g. https://gitlab.com |
projects |
project paths whose MRs are eligible |
rtRepos |
maps each project path to the rt repo identity holding its MR store |
members |
array of { "username", "name"? }, the teammates whose authored MRs the board shows, in sidebar order |
defaultMember |
member username the board opens to, or "all" |
title |
page heading and tab title |
slack |
review channel, post templates, and signal emoji |
The listen port is $PORT, default 7930, and the server always binds
127.0.0.1.
Full configuration reference covers every field, tabs, the settings-store ownership latch and its reload rules, and all the tokens and secrets.
Everything the board writes at runtime (review/respond/doctor lifecycle
rows, drafts, nudges, the outbox, Slack refs, triage memory, the
agent-status cursor) lives in one SQLite file, state.db, opened lazily on
first use and shared by the server and every CLI it launches. Its location
is independent of config.json's: BOARD_STATE_DB pins an exact file path
when set, otherwise it defaults to ~/.mattstack/board/state.db regardless
of which checkout or BOARD_APP_ROOT is running the server. That path must
still be named state.db (a launched pane's status CLI always derives
<root>/state.db from its claim ticket, never from BOARD_STATE_DB
itself, so any other basename would silently split the server's db from
every pane's); a mismatched basename fails fast rather than quietly forking
state. A first run against an existing install one-shot
imports any legacy per-lane JSON state files it finds and renames the old
state/ directory aside.
Because that path ignores which checkout is running, several boards started
by hand (in worktrees, say) share one state root with the supervised one,
and each would react to the same transition: one review's done signal
once produced four re-review latch threads on the same MR, one per process.
So a board claims a writer lease at boot, a writer/lease row in
state.db, and only the holder runs the autonomous side: the agent-status
feed, the gate sweep, the boot resume pass, the legacy-session migration,
the rt.notify.eventBridges rule and the peer tick. Everyone else serves
the UI, the read API and SSE exactly as before, and logs one line saying
which pid owns the root.
A board deck supervises outranks one started by hand (deck injects
MATTSTACK_CANONICAL_HOST, which is the signal), so it takes the lease even
when it boots last, and the displaced process stands its own write side down
within one 15s heartbeat. A lease whose holder has died, or whose heartbeat
is over 60s old, is free for the next board to claim.
Deciding and writing the lease happen in one IMMEDIATE transaction, so two
boots cannot both read the same free lease and both believe they won it, and
a lease only counts when the db acknowledged it. A heartbeat lost to
contention (state.db gives the server a 250ms busy timeout, so that is
ordinary) is not a displacement: the holder keeps the lease until the beat
it last committed is stale enough for another board to claim anyway.
A launched pane (review, respond, doctor) never sees BOARD_STATE_DB
itself. Instead the server hands it a claim ticket: a --state <path>
argv value that is really an opaque handle, <root>/state/<lane>s/<slug>.json,
from which the status CLI (bin/review-status.ts, bin/respond-status.ts,
bin/doctor-status.ts, bin/gate.ts) derives the same state.db by
walking up from the handle to its root. The CLI resolves its row by handle
and updates it; an unrecognized handle is a loud error rather than a
silently created record.
The board plugs into a channel convention rather than inventing its own workflow: review requests are messages in one channel containing the MR's URL, and review state is signalled with three reactions on that message (looking, commented, approved). If your team already reviews this way, the board drops in as-is.
Setup is one Slack app for the team, created from the checked-in
slack-app-manifest.yaml, then one OAuth flow per
teammate. The three emoji names are configurable, and drive the row chips, the
mark actions, and the reaction the board drops automatically when a launched
review starts or lands.
Opened from a local hostname, the row menu can hand an MR to a Claude Code agent in a fresh herdr pane:
- launch review or follow-up review someone else's MR
- respond to review on your own
- call the doctor on merge conflicts or red CI
The board injects the domain skill and a status-writer path as flags, so the wrapper skills carry no repo- or team-specific knowledge. The wrapper reports lifecycle status back through a state file and a matching event on the rt daemon's bus; the board reads that bus from a cursor it keeps, so a transition that lands while the board is down is replayed at its next boot. The row shows a live badge, and the board owns every Slack reaction, so the agent never touches Slack.
The gate is enforced on both sides: the client leaves the agent items out of
the menu and the server returns 403, so these never fire through a public
tunnel. A remote board shows one greyed-out "agent actions" row in their
place, noting that they need a local board.
There is also a one-shot automation pass for a cron entry, off by default:
./bin/board triageAgent actions guide covers the skill bindings in
.mattstack/skills.jsonc, operator notes, held drafts, and every triage
guardrail.
When teammates each run their own board, a small relay called the switchboard lets those boards nudge each other about reviews and re-reviews without either board talking to the other directly. It adds live peer badges, row actions to request a review, a re-review, or a response from a teammate's agent, and optional guarded auto dispatch on the receiving side.
It is entirely optional. Skip it and the board works exactly as described above.
The server binds a local port and has no auth of its own, so anything public-facing must bring its own gate. The setup this was built for is a persistent process behind a Cloudflare tunnel with Cloudflare Access in front of it.
The MR titles, branch names, and reviewer names on this page are your organization's internal data. Do not expose it without the gate.
bun test # unit tests: filtering, grouping, cache, ticket extraction, peers
bun run typecheck # server and client projects
bun run serve # the client is bundled in memory at startup; restart to pick up changes
bun run build # standalone binary at dist/boardboard is one app in mattstack, a personal
developer estate: rt is the CLI and daemon
this board reads its MR data from,
deck (in this same repo) serves it locally at
board.mattstack, gitq (in this same repo) manages stacked
branches, glance (in this same repo) models the forge data,
and skills (in this same repo) plus the
marketplace carry the
agent skills the row actions invoke.
Issues and pull requests are welcome. Before opening one:
- Run
bun testandbun run typecheck. - Keep the TypeScript strict, and keep new configuration in
docs/configuration.mdrather than only in code. - Never commit
config.jsonor.env. They are gitignored for a reason: they carry real names, tokens, and MR URLs.state.dblives outside the repo entirely (~/.mattstack/board/state.dbby default), so it never shows up here to begin with.
MIT. See LICENSE.