Google Docs for markdown — comment on, edit, and ship .md files like it's a real document. Paste a URL, drag-select text, leave a margin comment. Your teammates see it in real time. Edit the raw markdown in a native CodeMirror editor with a formatting toolbar, find-and-replace, and live preview. Resolve threads, then hand them to Claude for an AI-revised version — or push your edits straight back to GitHub as a pull request or direct commit. Agents join the same review through an MCP server: they read what humans read, leave threads humans approve, and (with explicit human sign-off) apply the resolved feedback as a new revision.
Unlike Google Docs, edits happen on the actual markdown — so the file in your repo stays the source of truth and the round-trip back to GitHub is one click.
Live: https://mumd.metavert.io/
Markdown is where a lot of real thinking lives now — PRDs, design docs, RFCs, release notes, prompts, briefs. But the tools for reviewing it are miserable:
- GitHub PRs force every discussion through a code-review workflow. Fine for production code, painful for a quick "this paragraph is unclear" on a brainstorming doc you haven't even branched yet.
- HackMD / Notion / Dropbox Paper lock your content into their format, pull you out of
.md, and require everyone to make an account. - Pasting into Google Docs drops all your formatting and now you have two sources of truth.
You just want to drag-select a sentence and leave a comment. Like Google Docs. On a real markdown file. And in 2026 you probably also want an agent on your review team — but you don't want to give that agent your whole identity, and you definitely don't want it shipping changes without you signing off.
A small self-contained web app:
- Backend: one Go binary (~12 MB Alpine image) talking to MongoDB
- Frontend: a single React SPA served from the same binary
- Storage: per-doc copy in Mongo, that's it — no S3, no Redis, no queue
- Deploy: one Fly machine, scales to zero, ~free for personal use
Everything you'd expect from a Google-Docs-style review experience, in a codebase small enough to read on a Sunday.
- Open any markdown file — paste a URL (raw URL or
github.com/.../blob/.../*.md, we auto-rewrite) or upload from your computer. Relative image refs (<img src=".github/logo.svg">) resolve against the source so READMEs render properly. - Drag-select text → comment. Click the floating "Comment" button, type, submit. Use
@usernameto mention someone — they get an in-app notification. - Threaded replies, mark-as-done, reopen, edit, delete. Same model your team already knows.
- Realtime sync: every change propagates to every other open tab in <1s via Server-Sent Events.
- Unread filter pill — shows you only the threads with new activity since your last visit, with a count badge.
- Step through comments with
j/k(or↑/↓, or the Prev/Next buttons). The position counter respects whichever filter is active. - In-app notifications — bell icon in the header, badge for unread count, dropdown with deep links into the relevant comment.
- Native markdown editor — click Edit and you get a real CodeMirror 6 editor with syntax highlighting, light/dark theme, and a formatting toolbar (bold, italic, code, H1/H2/H3, lists, task list, blockquote, link, code block, HR). The toolbar stays pinned at the top of the editor as you scroll long documents.
⌘Sto save as a new revision,⌘Ffor find & replace (regex + case-sensitivity),Escto cancel. Comments stay anchored to their text spans as you edit; the comment sidebar tracks the same lines in real time. A soft edit lock prevents two people from clobbering each other — whoever clicks Edit second sees a banner with the holder's name. Click Show preview for a side-by-side rendered view. - Smart formatting toolbar — selecting
**bold**and clickingBremoves the markers (toggles off) instead of doubling them up. Same for italic, code, headings. The selection overshoot pattern (**bold**with the markers included) is detected and stripped cleanly. - Push changes back to GitHub — for docs cloned from a GitHub blob URL, click Push to GitHub on any revision. Two modes: open a pull request from a new branch (with prefilled title + body that reference the doc), or commit directly to a branch you pick (typically
main). Branch-protection rules are enforced on GitHub's side and surfaced verbatim if they reject the push. The OAuth token already in your session is what authenticates — no separate GitHub PAT needed. - GitHub source-change detection — for docs cloned from GitHub, we track the upstream blob SHA. When the source file gets new commits, every viewer sees a "Source updated on GitHub" banner. Click Sync from GitHub and we pull the latest content and re-anchor your comments to the new text automatically wherever the original quote still appears. Comments whose quoted text no longer exists surface in a dedicated Comments without anchors section at the bottom of the doc — re-anchor them to new text manually (drag-select, click Re-anchor here), pin them as document-level, or just leave them. Document-level comments (no inline highlight) live in their own sidebar section and survive any source change.
- Revision tree — every save (manual edit or AI revision) creates a new child document with a parent link. Open the chain breadcrumb to jump between versions; the home list dedups to the latest leaf so your Recents aren't full of intermediate drafts.
- Soft delete with 30-day recovery — deleted docs sit in Trash and can be restored before the daily purge sweep.
- Light / dark theme that respects your system pref.
- Share dialog — copies the link with an explicit note about access (private docs warn you about the GitHub-repo requirement before you send the URL).
- Per-tab title and Open Graph link unfurls so shared URLs look meaningful in Slack/iMessage. Private docs share a generic card so titles don't leak.
- Human-readable URLs. Paste
mumd.metavert.io/owner/repo/blob/ref/pathand it Just Works as the doc URL.mumd.metavert.io/owner/repoopens a repo index;mumd.metavert.io/owneropens a user or org index (auto-detected). When you arrive via a/d/:idor/i/:idpermalink, the address bar auto-rewrites to the human form on mount, so copy-paste from the URL bar always gives you the shareable shape.
Indexes are shareable listings of .md files anchored to a GitHub resource. The point: a team has dozens of markdown docs scattered across repos (PRD.md, RFC-*.md, README.md, CLAUDE.md, …), and there's no good way to navigate them as a collection. Paste one URL and you get a curated index your whole team can browse and bookmark.
- Three URL shapes recognized at the home-page URL bar:
github.com/owner/repo→ repo index, listing every.mdfile in the repo's git tree (one round-trip via/git/trees?recursive=1).github.com/owner→ user or org index (auto-detected via/users/{name}.type), listing each repo's top-level.mdfiles grouped by repo.
- Shareable, viewer-scoped access. Each index lives at its own permalink and rewrites to a human URL (
mumd.metavert.io/anthropics). Items are computed live on every view using the viewer's GitHub token — different viewers see different listings if their repo access differs. Private-repo indexes re-verify access on every read (same model as private docs); private items in user/org listings are filtered to the original scanner's audience so cached file names don't leak to non-members. - Live progress. Org spiders show a real-time activity log (last 8 repos with file counts), progress bar, and X/Y counter. The per-repo fetches fan out across a worker pool of 8 —
github.com/beamable(~150 repos) completes in under 10 s instead of 60+. - Server-side caching + explicit Refresh. The first scan persists; subsequent visits load instantly from cache. A circular-arrow Refresh button in the page header re-spiders on demand. No accidental re-burning of GitHub rate limits.
- Filename filter tabs. Save up to 5 case-insensitive substring filters (e.g.
claude.md,_PRD,RFC) as named chips along the top. "All" is always present; tabs persist per-(browser, index) in localStorage; the last-active tab reopens on return. - Pinned default filter (owner). The index creator can pin one tab as the default view for share-link visitors. First-time visitors land on it; once they pick their own tab their choice takes over.
- Click → open in markupmarkdown. Each row resolves to a doc via the existing find-or-create flow, so comments aggregate on the same doc across viewers rather than fracturing into N parallel clones.
The home page surfaces Your indexes above Your documents so a saved index becomes the natural jumping-off point for browsing a team's markdown library.
- Sign in with GitHub to use your avatar and display name automatically, and to unlock private repo files (the OAuth app needs
reposcope). - Private docs stay private: if a doc was cloned from a repo that required GitHub auth to read, every subsequent view re-verifies that the current user has GitHub access. No access? No content shown — they can't even see the title.
- Bring your own Anthropic API key (stored encrypted at rest, deletable any time).
- Click Revise with AI, choose which resolved comments to apply (all by default — uncheck any you want to skip), and Claude Opus 4.7 produces a revised version that incorporates the agreed feedback while changing as little of the rest as possible.
- The output streams as rendered markdown in real time — you watch headings and paragraphs materialize as Claude writes them.
- Word-level diff preview before you accept. Saving creates a new document with a parent backlink, so revisions form a tree. The original (and its comments) stay untouched.
Agent collaboration (new — see Agents below)
- Personal access tokens authenticate scripts and agents to the same REST API humans use.
- MCP server at
/mcpexposes the review primitives as Model Context Protocol tools — any MCP-aware agent (Claude Code, Claude Agent SDK apps, custom tools) can read docs, leave threads, reply to humans, resolve, and trigger AI revisions with human approval. - Agent identity badges — comments and replies created via a token marked "for an agent" get a small bot badge so humans can scan a thread and instantly see who's whom.
This isn't a SaaS. The whole stack:
fly machine (512 MB)
└── markupmarkdown (Go binary)
├── /api/* — gorilla/mux router (REST)
├── /mcp — Model Context Protocol server for agents
├── /SKILL.md — canonical agent integration guide (raw markdown)
├── / — SPA from /app/web/dist
└── MongoDB Atlas — documents, comments, sessions, notifications, tokens
No build-time JavaScript on the server. No background workers besides a daily purge sweep and a bounded view-recording queue. No webhooks. No analytics SDK. No cookie banner because the only cookie is a session ID. Bring your own Anthropic key — your usage, your bill, your data.
git clone <this-repo>
cd markupmarkdown
# Backend
cd backend
cp .env.example .env # fill in MONGODB_URI + ENCRYPTION_KEY
go run ./cmd/markupmarkdown
# Frontend (separate terminal)
cd frontend
npm install && npm run devOpen http://localhost:4720/.
openssl rand -hex 32Set it as MARKUPMARKDOWN_ENCRYPTION_KEY in backend/.env (for dev) and as a Fly secret (for prod). This key encrypts per-user Anthropic API keys at rest using AES-256-GCM — without it, the AI-revision feature is disabled but everything else keeps working. Ciphertexts are prefixed with a version (v1:) so you can rotate by setting MARKUPMARKDOWN_ENCRYPTION_KEY_V2=<old> and updating the primary key.
Login + private repos are opt-in. Register an OAuth app at https://github.com/settings/applications/new:
| Field | Value |
|---|---|
| Homepage URL | http://localhost:4720 (dev) / your prod URL |
| Callback URL | http://localhost:4721/api/auth/github/callback (dev) / <your-prod-url>/api/auth/github/callback |
Add to env:
GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Scope repo enables reading private files; drop to read:user user:email if you only want public docs.
fly launch # or `fly apps create` if you already have a name
fly secrets set MONGODB_URI="mongodb+srv://..."
fly secrets set MARKUPMARKDOWN_ENCRYPTION_KEY="$(openssl rand -hex 32)"
# optional, after registering the OAuth app:
fly secrets set GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=...
fly deployThe included Dockerfile is a 3-stage build (frontend → backend → alpine runtime). Final image is ~12 MB.
markupmarkdown is built for agent + human review on the same doc. The model is simple: agents see what humans see, contribute the same way (threaded comments anchored to text spans), and the human stays in the loop for anything irreversible — including AI revisions.
Humans grant agents access by minting Personal access tokens in their avatar menu → Personal access tokens:
- Format:
mmk_<64 hex characters> - Stored hashed (SHA-256); plaintext is shown once at creation
- Tag a token as "for an agent" so every comment/reply it creates gets the bot badge
- Revocable any time
The agent passes the token as Authorization: Bearer mmk_… on REST and MCP calls.
Streamable HTTP transport at /mcp, built on github.com/mark3labs/mcp-go. Authenticate every call with Authorization: Bearer mmk_…. Fourteen tools cover the same surface humans see in the web UI; every one routes through the same access checks, scope enforcement, validation, and rate-limit buckets as REST — there is no agent-only fast path.
| Tool | Purpose |
|---|---|
list_documents |
Docs the calling identity has touched. Set include_trash: true to include soft-deleted docs. |
get_document |
Full markdown content + metadata, including parent/rootDocument/latestDescendant, source drift state, and revision index. |
list_comments |
Threads on a doc — filter by open / resolved / all, optionally pre-render bodies to HTML via render_html: true. |
list_revisions |
The full revision chain (root → leaf) for a doc with each node's revisionIndex, model, generatedBy, actorKind, and timestamps. |
| Tool | Purpose |
|---|---|
add_comment |
Anchor a new thread to a verbatim substring of the doc. Pass occurrence: N (1-based) when the substring appears multiple times. |
reply |
Reply to an existing thread. |
resolve_comment / reopen_comment |
Lifecycle. Resolved threads become eligible inputs for revise_with_ai. |
patch_anchor |
Re-anchor an orphan comment, or convert any comment to a document-level pin (doc_level: true). Mine-only. |
delete_comment |
Remove a thread your token authored. Mine-only — same require-mine guard as REST. |
| Tool | Purpose |
|---|---|
edit_document |
Save a new manual revision by sending the full new content. Creates a new child doc; unresolved comments carry forward and are re-anchored against the new content. |
revise_with_ai |
Run Claude Opus 4.7 over the doc + selected resolved threads. Preview-only by default (accept: false); pass accept: true to save as a new child doc. Uses the human user's stored Anthropic key. |
merge_from_github |
Reconcile a doc with its upstream GitHub source via a 3-way Claude merge (ancestor = source the revision was based on, ours = current doc, theirs = new upstream). Persists the merged content and re-anchors comments. Trivial cases bypass Claude. |
push_to_github |
Open a pull request from the doc's current content back to its source repo. PR mode only over MCP — direct-commit is web-UI-only for safety. Only push when a human has explicitly asked. |
The full agent guide — conventions, identity model, rate limits, scope hierarchy, when-to-edit-vs-revise-vs-merge, and out-of-scope actions — lives at skills/markupmarkdown/SKILL.md and is served live at https://mumd.metavert.io/SKILL.md.
All seven examples below are real MCP requests against the live /mcp endpoint. Each "name" matches the tool in the tables above; arguments is the literal payload.
{
"name": "add_comment",
"arguments": {
"document_id": "a3f7c2...",
"quoted_text": "we may scale linearly",
"body": "This claim is unsupported — the benchmark in §4.2 shows sub-linear scaling above 64 cores. Suggest softening to 'scales well up to medium concurrency'."
}
}If the quoted text appears multiple times in the doc, the tool returns an error — disambiguate with "occurrence": 2.
{
"name": "reply",
"arguments": {
"comment_id": "c1d2e3...",
"body": "Sources for the original benchmark: [link]. I can produce a revised graph if useful."
}
}// Preview only — does NOT save:
{
"name": "revise_with_ai",
"arguments": {
"document_id": "a3f7c2...",
"accept": false
}
}
// → { originalContent, revisedContent, model, tokensIn, tokensOut, appliedCommentIds }
// After the human approves the diff, save as a new child doc:
{
"name": "revise_with_ai",
"arguments": {
"document_id": "a3f7c2...",
"accept": true,
"comment_ids": ["c1d2e3...", "c4d5e6..."] // optional subset
}
}
// → { ..., newDocumentId: "b8c4d1..." }The revision uses the human user's stored Anthropic key — the agent never sees it and never gets billed.
// First read the current content so you can splice in your change:
{ "name": "get_document", "arguments": { "id": "a3f7c2..." } }
// → { content: "...", revisionIndex: 4, ... }
// Then save the edited content as a new revision in the chain.
// Unresolved comments carry forward and re-anchor against the new text.
{
"name": "edit_document",
"arguments": {
"document_id": "a3f7c2...",
"content": "<the full new markdown>",
"revision_note": "Fix the §4.2 scaling claim per Jon's review"
}
}
// → { newDocumentId: "d9e3f1...", revisionIndex: 5, carriedForward: 3, orphaned: 0 }Use this when you've decided on a specific change yourself, rather than asking Claude to derive it from resolved threads.
{
"name": "list_revisions",
"arguments": { "document_id": "a3f7c2..." }
}
// → [
// { id: "a3f7c2...", revisionIndex: 1, model: "manual", generatedBy: "jon", actorKind: "human" },
// { id: "b8c4d1...", revisionIndex: 2, model: "claude-opus-4-7", generatedBy: "jon", actorKind: "human" },
// { id: "d9e3f1...", revisionIndex: 3, model: "manual", generatedBy: "claude-code-laptop", actorKind: "agent" }
// ]get_document's latestDescendant answers the same question with a single call. list_revisions is the right choice when you want the full chain (e.g. to show a version picker).
// 1. The doc's source-drift indicators say upstream changed. Reconcile via 3-way merge:
{
"name": "merge_from_github",
"arguments": { "document_id": "a3f7c2..." }
}
// → { merged: true, mergedContent: "...", orphanedComments: 1, trivial: false }
// 2. After the human reviews the merge and resolves any conflicts, push the result
// back as a pull request. PR mode is the only mode available over MCP — direct
// commits are intentionally web-UI-only so branch-protection enforcement stays
// a human decision.
{
"name": "push_to_github",
"arguments": {
"document_id": "a3f7c2...",
"branch": "claude/wingman-prd-revisions",
"commit_message": "PRD: tighten §4.2 scaling claim, address resolved review threads",
"pr_title": "PRD revision: scaling claim + resolved review threads",
"pr_body": "Applies the comment threads resolved in https://mumd.metavert.io/d/a3f7c2..."
}
}
// → { mode: "pr", branch: "...", commitSha: "...", commitUrl: "...", prNumber: 142, prUrl: "..." }Only push when a human has explicitly asked. A resolved comment that says "ship this" counts; a periodic cron does not.
Every MCP tool corresponds to a REST endpoint at /api/... — the same Bearer token authenticates both. Use REST when you need an endpoint MCP doesn't expose (notifications, trash, SSE event streams) or your runtime isn't MCP-aware.
- Always quote verbatim in
add_comment— paraphrasing breaks anchoring. - One thread per concern — easier for humans to review, easier for
revise_with_aito apply cleanly. - Use markdown formatting in bodies — humans see it rendered, other agents can fetch HTML via
render_html: true. - Mention humans explicitly with
@github-loginwhen you want their attention. - Don't resolve your own threads unless the human told you to.
- Treat
revise_with_aias privileged — preferaccept: falsefirst, surface the diff, only then callaccept: true. - Respect rate limits. Token budgets are per-user; bursts > 30/min get
429s.
# Documents + comments
POST /api/documents { url, title? } | { content, title }
GET /api/documents your recent docs (requires sign-in)
GET /api/me/trash your soft-deleted docs (within 30-day window)
POST /api/documents/:id/restore restore a soft-deleted doc
GET /api/documents/:id
PATCH /api/documents/:id { title }
DELETE /api/documents/:id soft delete
GET /api/documents/:id/comments ?render=html to include sanitized HTML bodies
POST /api/documents/:id/comments { anchor:{start,end,exact}, body, author }
POST /api/documents/:id/sync pull latest source from GitHub + auto re-anchor comments
GET /api/documents/:id/events SSE stream — comments-updated + doc-updated events
# Manual editing + GitHub round-trip
POST /api/documents/:id/manual-revisions { content } → new child doc (manual edit save)
GET /api/documents/:id/edit-lock current soft lock holder
POST /api/documents/:id/edit-lock claim the soft edit lock for the doc
DELETE /api/documents/:id/edit-lock release the soft edit lock
GET /api/documents/:id/pushback/info GitHub repo + branch info for the push UI
POST /api/documents/:id/pushback { mode: "pr"|"direct", branch, commitMessage, ... }
GET /api/documents/:id/mention-candidates people known to this doc, for @-autocomplete
GET /api/documents/by-source ?owner=X&repo=Y&ref=Z&path=W → existing doc id
# Markdown indexes
POST /api/indexes { url } → meta-only (items stream separately)
GET /api/indexes/:id meta + items (synchronous, cached)
GET /api/indexes/:id/stream SSE — meta / scanning / items / done events
?refresh=1 forces a fresh GitHub spider
PATCH /api/indexes/:id { title?, defaultFilter? } (creator-only)
DELETE /api/indexes/:id soft-delete
POST /api/indexes/:id/forget hide from MY home list only
GET /api/me/indexes your live (non-forgotten) indexes
PATCH /api/comments/:id { body }
DELETE /api/comments/:id
POST /api/comments/:id/resolve { author }
POST /api/comments/:id/reopen
PATCH /api/comments/:id/anchor { start, end, exact } or { docLevel:true }
POST /api/comments/:id/replies { body, author }
PATCH /api/comments/:id/replies/:replyId { body }
DELETE /api/comments/:id/replies/:replyId
# Auth + identity
GET /api/auth/config { githubEnabled, githubClientId }
GET /api/auth/me { user }
GET /api/auth/github/login?redirect=/d/...
GET /api/auth/github/callback
POST /api/auth/logout
GET /api/me/notifications { unread, notifications }
POST /api/me/notifications/read mark all read
POST /api/me/notifications/:id/read mark one read
GET /api/me/tokens your active personal access tokens
POST /api/me/tokens { label, isAgent } → token (shown once)
DELETE /api/me/tokens/:id revoke
GET /api/me/anthropic-key { hasKey, hint }
PUT /api/me/anthropic-key { key }
DELETE /api/me/anthropic-key
# AI revision (Server-Sent Events streaming)
POST /api/documents/:id/revise stream — delta / done / error
POST /api/documents/:id/revisions accept a previewed revision
# Agent guide
GET /SKILL.md canonical SKILL.md (raw markdown; /skill.md and /skill also work)
# MCP
* /mcp streamable HTTP MCP server (Bearer auth)
- Comment anchoring uses character offsets into the rendered markdown's
textContent. Cloning the source freezes the offsets so they stay valid forever. Agent comments anchor by text-substring; the frontend resolves them to offsets at render time. See frontend/src/utils/anchor.ts. - Source-change detection stamps the GitHub blob SHA on each doc at ingest. Every doc-open triggers an async, cached (10-min TTL) re-check against the Contents API — if the upstream SHA differs, the next view sees a Source updated on GitHub banner. The sync flow re-fetches the new content, then walks every comment: those whose quoted text is still present un-orphan and defer to the textContent fallback for highlight positioning; the rest flip to
orphan: truewith their original quote preserved inoriginalExact, ready for manual re-anchor. Orphan, doc-level (no inline anchor), and regular anchored comments each occupy their own UI region. See backend/internal/api/source.go. - Realtime is a per-document in-memory hub fan-out over SSE. No external pub/sub. Disconnections auto-reconnect via the browser's EventSource.
- AI revision streams Anthropic's response straight through to the browser, so you see Claude writing in real time. Backend-side timeout is 10 minutes; client-side abort is 5 minutes.
- API key encryption: AES-256-GCM with a random nonce per encryption. The 32-byte master key lives only in
MARKUPMARKDOWN_ENCRYPTION_KEY(env var / Fly secret), never in MongoDB. Encrypted blobs live in a separateuser_secretscollection so the field can't accidentally leak via/api/auth/me. Hint = first 10 + last 4 characters. Key versioning supports rotation without re-encryption ceremonies. - Personal access tokens are stored as
sha256(token); the plaintext is shown once and never logged. - SSRF guard: the URL fetcher refuses to dial RFC1918, loopback, link-local, metadata, and CGNAT IPs at both initial connection and every redirect.
- Rate limits + body caps + concurrency semaphores keep a single Fly machine resilient against burst load and abusive clients. See
backend/internal/limits/. - MCP server: streamable HTTP at
/mcp, Bearer-auth, every tool routes through the same access checks as the REST API.
cd backend && go build ./...
cd frontend && npx tsc --noEmitThe Go backend has a unit + integration test suite that exercises the HTTP handlers against a real MongoDB Atlas instance. Coverage is tracked in Codecov with an 80% project target.
Current coverage: 80.4% on the audited surface (per Codecov, with
cmd/, testutil/, the embedded SKILL.md, and the SPA file-server
explicitly excluded — see codecov.yml). The raw go tool cover total
across every file is 72.7%; both numbers are honest, they just measure
different scopes. The audited number is what codecov.yml's 80% gate
applies to.
The frontend has a separate suite: Vitest for unit tests on the utility modules, Playwright for end-to-end browser flows.
cd backend
# Run the full suite (unit + integration), no coverage:
make test
# Just unit tests (no DB needed):
make test-unit
# Coverage report:
make test-coverage # writes coverage.out + coverage.htmlIntegration tests connect to a separate MongoDB database named
markupmarkdown-test (configured in backend/config/test.yaml). The
internal/testutil package hard-refuses to run if the resolved DB name
doesn't contain test, so the suite physically cannot touch prod
(markupmarkdown) or dev (markupmarkdown-dev) data. Each test starts
from an empty database (via DeleteMany rather than Drop, to preserve
indexes across packages).
To run integration tests locally, copy backend/.env.test.example to
backend/.env.test and fill in MONGODB_URI. If the URI is unset the
integration tests are skipped (not failed) so unit-only runs are fast.
cd frontend
npm install
npx playwright install --with-deps chromium
npm run test:e2eE2E tests live in frontend/e2e/. The Playwright config points at
http://localhost:4720 by default — start npm run dev (and the
backend on port 4721) before running locally.
.github/workflows/ci.yml runs the build, type-check, Go tests with
coverage, and Playwright tests on every push and PR. Coverage is
uploaded to Codecov via CODECOV_TOKEN. The codecov.yml at the repo
root configures the 80% project target and excludes packages that are
plumbing rather than logic (cmd/, testutil/, embedded assets, the
SPA file server). The badges at the top of this README reflect the
status of the latest run on master.
MIT © 2026 Metavert LLC