Contributing to Concord
Concord is a peer-to-peer, end-to-end-encrypted chat app: a Go core (identity, MLS, libp2p, storage) with a Svelte UI that the same core serves either into a browser or into a native desktop window.
Before changing anything substantial, read docs/DESIGN.md. It is long on purpose. It explains why the design is the way it is, and most of the questions a newcomer has ("why is there a server at all?", "why can't two people commit at once?") are answered there rather than in the code.
Security issues do not belong in an issue or a pull request. See SECURITY.md.
Where to start
- Issues labelled
good first issueare small, self-contained, and say where the code is.help wantedare larger but scoped. - §14 of DESIGN.md maps the tree:
internal/is the Go core (identity, MLS, networking, storage, sync),internal/bridgeis the RPC surface the UI calls,frontend/srcis the Svelte UI,apps/mobilewraps it for Android and iOS,cmd/rendezvousis the optional node. make peersgives you two local peers to break things between, which is where most bugs live (see the multi-peer dev loop).
Prerequisites
- Go: the version in
go.mod(currently 1.26.3) or newer. - Node 22 or newer. That is what CI uses; older majors are untested.
webkit2gtk-4.1, for the native desktop build (make gui) only. The browser-served build needs nothing beyond Go.
The MLS implementation is vendored under third_party/mls-go and wired up with
a replace in go.mod, so there is no extra fetch step.
Mobile (make android-core, make ios-core) additionally needs gomobile plus
an Android NDK or Xcode. You need none of it to work on the core or the UI.
One-time setup
git config core.hooksPath .githooks
Commit authors here are GitHub noreply addresses. That line turns on two hooks
in .githooks/ which check it, one before the commit is written
and one before it is pushed. Yours is shown on
https://github.com/settings/emails and looks like
ID+username@users.noreply.github.com.
Git does not clone hooks, so this is per-clone.
Building
make help lists every target. The two that matter:
make web # browser-served binary -> bin/concord-web, then open :8787
make gui # native desktop window -> bin/concord-gui
Both rebuild the frontend first. That ordering is not optional: the UI is
embedded into the Go binary, so a go build without a fresh frontend/dist
silently ships the previous UI.
make release cross-compiles the zero-dependency web binaries into
dist-release/. It publishes nothing; it is a local smoke test.
Tests
make test # go test ./internal/... . (includes a multi-node E2EE integration test)
make race # the same under the race detector
make fmt # gofmt the Go tree
cd frontend && npm run lint # the no-undef gate: catches references to things that do not exist
cd frontend && npm test # markdown, render and other unit tests
cd frontend && npm run build # must succeed; a broken build is a broken release
On every pull request (and every push to main), CI runs go build ./...,
go vet ./..., go test -race -short ./..., the three frontend steps, and a
check that the documentation site in docs/manual/ was rebuilt (make docs). See
.github/workflows/ci.yml. It has to be green
before anything merges. gofmt is part of that gate, so run make fmt before
you push rather than discovering it in a red build. make fmt covers the root
package, internal/ and cmd/; CI also checks mobile/ and scripts/, so
gofmt those by hand if you touch them.
-short is deliberate: it skips the tests that stand up real libp2p nodes and
wait on message propagation, which are too timing-sensitive to gate a pull
request on a shared runner. Those run nightly in the integration job, and
make test runs the ones under internal/ locally (go test -race ./... also
covers cmd/) — so a change to the networking layer wants a
local make race before you push, because the gate will not catch it.
Run the rest locally first as well. The Go suite starts real libp2p hosts and is occasionally timing-flaky under load, so re-run a single failure on its own before you go hunting.
The multi-peer dev loop
Almost every interesting bug in Concord is a bug between two peers, so the normal way to work is to run several of them side by side. There is no server to point at: each peer is a whole app with its own identity and database.
make peers # 2 isolated peers on :8801 and :8802 (make peers N=3 for more)
make dev-clean # wipe the local test data in .dev/ afterwards
Open each printed URL in a separate browser window, unlock each with any
passphrase (the first unlock creates that peer's identity), then create a guild
in peer 1, copy its invite code, and join from peer 2. They find each other over
mDNS on loopback within a couple of seconds. Logs land in .dev/peer*.log. To
start one peer from scratch, delete just its directory: rm -rf .dev/peer2.
The ?t= on each printed URL is the local API token — the page stores it and
strips it from the address bar. Every request to the core needs it, so open the
URL as printed rather than retyping the bare host, or you get unauthorized.
You can also drive a peer without a browser. The UI talks to the core over a
small JSON-RPC endpoint, and so can you. make peers pins one token for the
run and prints it; a standalone binary mints a random one and prints it on the
URL at startup:
export CONCORD_API_TOKEN=<the token make peers printed>
curl -s -X POST http://127.0.0.1:8801/rpc \
-H "Authorization: Bearer $CONCORD_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"method":"Login","args":["any-passphrase"]}'
The available methods are the Dispatch switch in internal/bridge/bridge.go
and nothing else. curl -N "http://127.0.0.1:8801/events?token=$CONCORD_API_TOKEN"
streams the same server-sent events the UI reacts to, which is the fastest way
to see whether a change reached the other side — SSE cannot set headers, so it
takes the token as a query parameter. A CSRF guard additionally rejects requests
carrying a foreign Origin header; curl sends none, so it passes.
One shell gotcha: zsh reserves GID, so it is the one variable name not to
reach for when scripting guild IDs.
Driving the real UI without a browser window
For a change you want to see rather than assert on, chromium --headless --screenshot does not work — the SSE stream never lets the page reach a settled
state, so the capture hangs. Playwright against the system Chromium does, with
no browser download:
npm i playwright-core
import { chromium } from "playwright-core";
const browser = await chromium.launch({ executablePath: "/usr/bin/chromium" });
Then: load 127.0.0.1:8801, fill input[placeholder*="assphrase"] and press
Enter (the backend session survives a reload, but the UI always asks), then
press Escape to dismiss anything already open. Useful handles: member names are
.mname, the profile popover is [role="dialog"][aria-label*="profile"], and
the composer's input[type="file"] takes setInputFiles to send an attachment.
Clicking the guild-name header opens Guild settings, which is usually not what
you wanted. The smoke-* scripts are worked examples, and
scripts/playwright.mjs is the helper they use to find a playwright-core
install.
To exercise internet-style discovery without deploying anything, make rendezvous-run starts a local rendezvous node with a stable identity and prints
a multiaddr to point peers at via CONCORD_BOOTSTRAP.
One level up from that loop are the end-to-end harnesses in scripts/, which
drive real browsers against real peers: guest joins, calls, screen share, and
interop.sh, which runs a previous release as one peer and a build of your
branch as the other. Each script opens with a comment saying what it covers and
why. They need a playwright-core install (npm i playwright-core anywhere the
script can find it) and are not wired into npm test, because pulling a browser
toolchain onto every contributor's machine to run a harness most will never
touch is not a trade worth making.
Code style
Go is gofmt-formatted (make fmt) and passes go vet. Frontend code passes
npm run lint. Beyond that, the thing this codebase is strict about is
comments.
Comments explain why, never what
A comment that restates the code is worse than no comment: it goes stale, and it
teaches readers to skip comments. Write down the constraint, the failure that
forced the shape, the thing the next person will otherwise "simplify" back into
a bug. Real examples from the tree: why -skipbindings is required for the
Wails build, why the TURN relay block in fly.rendezvous.toml is commented out,
why the libc fork exists at all, why the governance backdating guard runs on the
live path but not on sync backfill. Each of those exists because someone lost
time to it.
The corollary is that a comment is a claim, and a claim that stops being true is a bug. If you change the shape a comment describes, change the comment in the same diff.
Prose
Comments, docs, commit messages and UI copy are plain. No marketing. If something is a known gap, say so; the threat model in docs/DESIGN.md is the tone to match.
Commit messages describe the change from the user's side ("A guild survives its owner vanishing: name an heir who can take the crown"), not the diff.
Pull requests
Open an issue before starting anything large. Two maintainers cannot absorb a rewrite that arrives unannounced, and hearing "we don't want this" before you build it is better than after.
Then:
- One concern per pull request. A refactor bundled with a behaviour change is hard to review and impossible to revert cleanly.
- Keep it small. A 200-line diff gets read closely. A 2,000-line one gets skimmed, and two people reviewing in their spare time will skim.
- Tests for anything with logic in it. If a bug was reachable, a test should reach it.
- Say how you verified it. "Two peers, invited, messaged both ways, restarted peer 2 and the history came back" is worth more than a green checkmark.
- Changes to the wire protocol, the storage schema, or governance rules need a
note on what happens when an older peer meets a newer one. There is no server
to migrate everybody at once, so both versions are live at the same time.
scripts/interop.shruns that pairing for you. - If your change alters what an attacker can do, update the threat model in docs/DESIGN.md in the same pull request.
- CI green before review, not after.
Who reviews what, and which changes need both maintainers to sign off, is in MAINTAINERS.md.
Sign your commits off
Commit with git commit -s. That appends a Signed-off-by: line carrying your
name and email.
The line is the Developer Certificate of Origin: by adding it you state that you wrote the patch, or otherwise have the right to submit it under this project's license. It is a claim about where the code came from, not a transfer of anything, and it costs one flag.
Licensing
Concord is licensed under the GNU Affero General Public License v3.0 or later (see LICENSE). By contributing, you agree that your contribution is licensed under AGPL-3.0-or-later.
There is no CLA and no copyright assignment. Contributors keep their own copyright, held collectively as "Concord contributors".