A local, config-driven dashboard for your git worktrees, Claude Code sessions, and GitHub
review queue. Everything runs on your machine — no accounts, no telemetry. A local Python
server collects your git/GitHub/Jira state, bakes it into one static HTML page, and serves it
at 127.0.0.1:7777; a handful of Claude Code agents fill in the parts that need judgement
(drafted PR reviews, Jira quick-win verdicts, decision execution).
▶ Live demo — the board with fictional data, no install needed.
If you use Claude Code plugins:
/plugin marketplace add haimbj1/mainstem
/plugin install mainstem@mainstem
(Your team can also list MainStem in its own marketplace — any entry pointing at this repo works.)
The plugin ships the skill and the four ms-* agents (.claude-plugin/plugin.json). Then run
the skill's own onboarding conversation:
/mainstem setup
It runs a doctor pass (checks git, gh, jq, python3, node, optionally tmux/iTerm2),
interviews you for workRoot, GitHub login/orgs, Jira host/projects, which modules to enable,
and the port, writes ~/.config/mainstem/config.json, and starts the server.
git clone https://github.com/haimbj1/mainstem
cd mainstem
./install.shinstall.sh runs ms_doctor.sh first and refuses to continue if a required tool is
missing (it prints the exact brew install ... fix). It then symlinks the skill and agents
into ~/.claude/skills and ~/.claude/agents, writes a default config.example.json copy to
~/.config/mainstem/config.json if none exists, installs the launchd job (macOS) or
systemd user unit (Linux), starts it, and prints the URL once /health answers. uninstall.sh
reverses the service and the symlinks; your config and dataDir are left untouched.
Either way, open http://127.0.0.1:7777 (or your configured port) once the server is up.
The plugin and install.sh are alternative delivery paths for the same skill and agents —
pick one, don't stack them: the plugin ships them through Claude Code's plugin system, the
standalone install symlinks them into ~/.claude/skills and ~/.claude/agents. When
switching from one to the other, run uninstall.sh first (it removes the service and the
symlinks; your config and dataDir stay). After a standalone install, open a new Claude
Code session and type /mainstem — sessions that were already running do not pick up newly
installed skills.
MainStem reads one merged JSON config — see config.example.json for a starting point,
and docs/architecture.md for the exact file-resolution order and merge rules. Fields:
| Field | Type | Default | Meaning |
|---|---|---|---|
brand |
string | "MainStem" |
Display name shown in the page header. |
workRoot |
path | ~/work |
Directory of git repos/worktrees to scan. |
host |
string | 127.0.0.1 |
Bind address. Set to a Tailscale/LAN address only if you want the remote-read mode below — see Security posture. |
port |
number | 7777 |
Local server port. |
github.login |
string | "" |
GitHub username; falls back to gh api user -q .login when empty. |
github.orgs |
array | [] |
GitHub orgs whose PRs and review requests to collect. |
jira.host |
string | "" |
Jira Cloud host, e.g. yourteam.atlassian.net. Empty disables Jira entirely. |
jira.email |
string | "" |
Jira account email, paired with an API token for jira_fetch.sh. |
jira.projects |
array | [] |
Jira project keys to filter to. An empty list disables ticket parsing even if jira.host is set. |
google.clientFile |
path | ~/.config/mainstem/google_client.json |
OAuth desktop-client JSON (downloaded from Google Cloud Console) for the tokenless bake. |
google.tokenFile |
path | ~/.config/mainstem/google_token.json |
Refresh-token file written once by google_auth_setup.py, read by google_fetch.py. |
reviews.watchRepos |
array | [] |
Extra repos whose open PRs feed the review queue even without a direct or team review request (shown with provenance: "watch"). |
bake.scheduledDaily |
bool | false |
Have install.sh schedule the tokenless bake daily at 08:30 (launchd on macOS, a systemd user timer on Linux) — see Scheduled bake. |
dataDir |
path | ~/.local/share/mainstem |
Where collected JSON and the built page live. |
reviewsDir |
path | ~/.claude/reviews |
Root of drafted-review .md files (an active/ subdirectory). |
sessionNotesDir |
path | ~/.claude/sessions |
Root of session handoff notes. |
masterHandoffNote |
path | ~/.claude/sessions/master-mainstem.md |
The one file a rotated master session reads on startup. |
masterTmuxSession |
string | "ms-master" |
tmux session name the page's Rotate-master button launches or respawns. |
rebuildIntervalSeconds |
number | 1800 |
How often the server re-collects and rebuilds on its own. |
noteRecollectThrottleSeconds |
number | 30 |
Minimum gap between session-note-triggered recollects on page load. |
modules.jira |
bool | false |
Show the Jira tab (needs a daily bake — see docs/architecture.md). |
modules.calendar |
bool | false |
Show the calendar section (needs a daily bake). |
modules.mail |
bool | false |
Show the mail summary line (needs a daily bake). |
modules.quickwins |
bool | false |
Show quick-win verdicts on Jira tickets. |
modules.reviews |
bool | true |
Show the PR review queue and drafted-review drawer. |
modules.jump |
bool | true |
Show the jump-to-session button (macOS + iTerm2 only). |
Every field above ships in config.example.json with its real default value. Override any of
them via ~/.config/mainstem/config.json, <repo>/config.local.json, or $MS_CONFIG.
- macOS is first-class:
install.shinstalls a launchd job; the jump-to-session button (modules.jump) drives iTerm2 via AppleScript;render_check.sh's screenshot helper expects Chrome at its default macOS install path. - Linux is degraded, not unsupported:
install.shinstalls a systemd user unit instead of launchd; jump-to-session (modules.jump) has no iTerm2 equivalent and stays off; everything else — server, collectors, reviews, sessions panel, publish — works the same.
The Jira / Calendar / mail panels come from a daily "bake". Next to the model-driven path
(the ms-refresher agent), skills/mainstem/scripts/bake.sh is a tokenless path — pure
curl + stdlib Python, safe to run headless:
jira_fetch.sh— Jira REST with an API token (keychain or~/.config/mainstem/jira_token).google_fetch.py— Calendar (next 3 days) and Gmail (up to 6 unread, metadata only) with a Google OAuth refresh token.bake_stamp.json— the freshness stamp; the board shows an amber "run the bake" banner when it is older than 24 h.
A source with no credentials is skipped and its files are left as is.
One-time Google setup:
- In Google Cloud Console, enable the Calendar and Gmail APIs and create an OAuth
client of type Desktop app (
google_auth_setup.pyprints the exact steps). - Save the downloaded client JSON to
google.clientFile. - Run
python3 skills/mainstem/scripts/google_auth_setup.py— it opens the browser for consent (read-only Calendar + Gmail scopes) and writesgoogle.tokenFile.
To run it on a schedule, set bake.scheduledDaily to true and rerun ./install.sh —
it installs a launchd job (io.mainstem.bake, macOS) or a systemd user timer
(mainstem-bake.timer, Linux), daily at 08:30, logging to <dataDir>/bake.log.
uninstall.sh removes it.
The server binds to 127.0.0.1 by default and only that. Nothing leaves your machine unless
you explicitly:
- ask a Claude session to publish a read-only snapshot as a Claude artifact, or
- opt into the LAN/Tailscale read-only mode documented below by setting
hostyourself.
POST /request (the only way to trigger a write — jump, refresh, delete a worktree, push a
branch, post a review) always refuses non-loopback callers, with no exception, even when the
LAN mode is enabled for reads. There is no telemetry, no analytics, and no outbound network call
except the ones you've already configured yourself (gh, jira_fetch.sh).
Two ways to check the board from your phone:
- Publish (recommended): ask your Claude session to publish the board — it renders a read-only snapshot (every action button hidden) as a private Claude artifact, viewable from any device.
- LAN/Tailscale (advanced, off by default, documented pattern rather than a built-in flag
today): set
hostin your config to your Tailscale or LAN address to bind there instead of127.0.0.1. A read-only?token=check on every GET is the intended gate for this mode but isn't implemented yet —tokenisn't a config field, and nothing enforces it — so until it lands, changinghostalone exposes reads to anyone who can reach that address, with no token gate.POST /requestalready always refuses non-loopback callers, no exceptions — writes only ever happen from the machine running the server, and that part holds today. This mode must never be exposed to the open internet, and is not a public tunnel.
docs/architecture.md— config resolution, every data file's shape, the request contract, session-kind classification, the review file format, and the read-only publish hand-off.CONTRIBUTING.md— running demo mode and CI checks locally.docs/PUBLISH.md— the manual pre-publish audit runbook (owner-only, never automated).CHANGELOG.md— release notes.