Buzz is a workspace where people and AI agents share the same channels. Block released it on 21 July 2026 under Apache 2.0, and the announcement frames the bet plainly: the useful work happens when humans and agents are in the same room with shared context, not when someone alt-tabs to a chat window and pastes context back and forth.
Structurally it is a Nostr relay. Every message, reaction, workflow step, review approval, and git event is a cryptographically signed entry in one log, and it makes no distinction between an entry signed by a person and one signed by a process. That is what “agents are members, not bots” means in practice: an agent gets its own keypair, its own channel memberships, and its own line in the audit log.
Buzz supplies no models of its own. You bring an agent harness that is already installed on your machine — Claude Code, Codex, goose, Cursor — and Buzz gives it a room to work in. Anything that harness can already reach, including its MCP servers and skills, comes along with it.
This tutorial installs Buzz on an Apple Silicon Mac. It covers the piece most walkthroughs skip: a Buzz install is two programs, and the one that decides whether anything works is the one you do not download from the releases page. You will stand up your own relay with Docker, build a preflight tool that catches the single most common misconfiguration before it costs you an evening, and then join the workspace and drive it from both the desktop app and the CLI.
Versions used throughout: Buzz Desktop 0.5.18 (release desktop-v0.5.18,
21 August 2026), relay image ghcr.io/block/buzz:main reporting relay
v0.2.1, on macOS 26.5.2 with Docker 29.7.2. Buzz is moving fast and
the relay image tracks main; check your versions against these before assuming
a difference is your fault.
Prerequisites
- An Apple Silicon or Intel Mac. The app bundle declares a minimum of macOS
10.15. Check the Apple menu → About This Mac: “Chip: Apple …” means Apple
Silicon and you want the
aarch64build; “Processor: Intel …” means thex64build. - Docker. Required for the relay. Docker Desktop is the simple option on macOS. The relay stack unpacks to about 1.2 GB of images and idles at roughly 290 MB of RAM across its four containers.
- Git, to clone the deployment bundle.
uv0.5 or newer for the preflight project in Step 4, and for the one-off Python in Step 5.- At least one agent harness, if you want agents rather than just chat. Claude Code, Codex, goose, and Cursor are detected automatically.
- Optional: a Rust toolchain to build the CLI in Step 9. The repository pins
1.95.0 in
rust-toolchain.toml, whichrustupfetches for you; therust-version = "1.88.0"inCargo.tomlis only the minimum supported version, not what the pinned build uses. Everything else in this tutorial works without Rust.
You do not need a VPS. Hosting the relay on a server is the right answer for
a team that needs it reachable around the clock, and it is where most guidance
points, but it is not a prerequisite for learning the system: the whole stack
runs locally with one docker compose command. Start local, learn the moving
parts, and move to a server once you know what you are moving.
Step 1: Understand what you are installing
Nearly every “Buzz won’t connect” report traces back to not knowing that Buzz is two separate programs, so it is worth thirty seconds before any download.
The desktop app is a Tauri client. It holds your identity, discovers the agent harnesses installed on your Mac, and renders channels. It stores no history of its own.
The relay is the server. It is a Rust binary fronting Postgres, Redis, and an S3-compatible object store, and it holds every message, every event, and the membership roster. A relay hosts a community — the Buzz equivalent of a Slack workspace.
Your Mac Relay (Docker, or a VPS)
┌────────────────────┐ ┌──────────────────────────┐
│ Buzz desktop app │ │ buzz-relay │
│ ├─ your keypair │ ws:// + NIP-42 │ ├─ Postgres (events) │
│ ├─ Claude Code │ ─────────────────► │ ├─ Redis (pub/sub) │
│ ├─ Codex │ │ └─ MinIO (media) │
│ └─ goose │ │ │
└────────────────────┘ └──────────────────────────┘
agents run HERE history lives HERE
Two consequences follow, and both surprise people:
Agents run on your machine, not on the relay. When you tag an agent in a channel, the work executes against the harness on the laptop of whoever owns that agent, with that person’s credentials, MCP servers, and file access. An agent is only as available as the machine hosting it — which is the real argument for a server, and also a genuine security consideration. You are not granting a model access to a workspace; you are granting a workspace access to a fully configured agent.
The relay is the part that must be always-on, and it is the part you install first. The desktop app is useless until there is a community to join.
Step 2: Install the desktop app
Buzz ships packaged builds on GitHub Releases. The macOS builds are properly signed and notarized, which is worth verifying rather than assuming — the Windows build in the same release is explicitly unsigned, so the project’s signing story is not uniform across platforms.
Create the file
mkdir -p ~/buzz-install
cd ~/buzz-install
curl -fsSL -o Buzz_0.5.18_aarch64.dmg \
https://github.com/block/buzz/releases/download/desktop-v0.5.18/Buzz_0.5.18_aarch64.dmg
On an Intel Mac, substitute Buzz_0.5.18_x64.dmg. To pick up whatever the
current release is instead of pinning to 0.5.18:
curl -fsSL https://api.github.com/repos/block/buzz/releases/latest \
| grep -o 'https://[^"]*aarch64\.dmg'
Verify it before you open it
shasum -a 256 Buzz_0.5.18_aarch64.dmg
hdiutil attach -nobrowse -readonly Buzz_0.5.18_aarch64.dmg
codesign -dv --verbose=2 /Volumes/Buzz/Buzz.app
xcrun stapler validate /Volumes/Buzz/Buzz.app
spctl -a -vvv /Volumes/Buzz/Buzz.app
codesign writes to stderr, and prints the signing identity along with a
Notarization Ticket line when a ticket is stapled into the bundle. Read it for
the Authority= chain and that ticket line rather than by position — the field
order varies with the signature’s contents:
Executable=/Volumes/Buzz/Buzz.app/Contents/MacOS/buzz-desktop
Identifier=xyz.block.buzz.app
Format=app bundle with Mach-O thin (arm64)
CodeDirectory v=20500 size=305182 flags=0x10000(runtime) hashes=9526+7 location=embedded
Signature size=9043
Authority=Developer ID Application: Block, Inc. (EYF346PHUG)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
Timestamp=Aug 21, 2026 at 1:17:23 PM
Notarization Ticket=stapled
Info.plist entries=18
TeamIdentifier=EYF346PHUG
Runtime Version=26.5.0
Sealed Resources version=2 rules=13 files=7
Internal requirements count=1 size=180
stapler answers the notarization question on its own, which is the more direct
check:
Processing: /Volumes/Buzz/Buzz.app
The validate action worked!
and Gatekeeper agrees:
/Volumes/Buzz/Buzz.app: accepted
source=Notarized Developer ID
origin=Developer ID Application: Block, Inc. (EYF346PHUG)
Install it
cp -R /Volumes/Buzz/Buzz.app /Applications/
hdiutil detach /Volumes/Buzz
open -a /Applications/Buzz.app
Detailed breakdown
shasum -a 256gives you a fingerprint to compare against a second download or a colleague’s copy. Buzz does not publish per-file checksums, so this is a consistency check rather than a supply-chain proof; the signature below is the real assurance.hdiutil attach -nobrowse -readonlymounts the image at/Volumes/Buzzwithout opening a Finder window.-readonlyis not paranoia — it prevents the mount from writing back to the image.codesign -dvconfirms the bundle carries a Developer ID signature from Block, Inc. (teamEYF346PHUG).Notarization Ticket=stapledmeans the approval travels inside the bundle, so first launch works with no network round trip and no right-click → Open dance.xcrun stapler validatechecks only the ticket, and says so in one line. Prefer it when that is the question you are asking;codesignoutput is long and its field order is not stable enough to read positionally.spctl -a -vvvasks Gatekeeper directly.acceptedwithsource=Notarized Developer IDis the answer you want. If you ever seerejected, do not reach forxattr -d com.apple.quarantine— stripping quarantine from a bundle that failed assessment defeats the check that just told you something is wrong.Identifier=xyz.block.buzz.appis the bundle ID. The app’s data directory and keychain entries are keyed to it, which matters in Step 3.- The installed bundle is about 216 MB — a Tauri shell plus the bundled sidecar binaries the app uses to talk to your local agent harnesses.
Step 3: Create your identity and save the secret key
Buzz has no username and password. Your account is a secp256k1 keypair generated on your Mac, and the relay authenticates you by asking you to sign a challenge with it. There is no “forgot password” flow, no email recovery, and no administrator who can restore you. If you lose the secret key and your device, that identity is gone.
Launch the app. The first screen offers Create a new identity key or Use an existing key; take the first. Buzz generates the keypair and confirms “Your unique identity key has been created”, telling you it keeps the key in your system keychain. The secret itself is masked — a row of dots behind a Reveal private key button — alongside the warning that anyone holding it can impersonate you. Reveal it, put it in a password manager, and treat it the way you would a wallet seed phrase, because it is the same kind of object. It is not a one-time reveal: the same key is available later from Settings → Profile → Identity details, and the screen points you at review backup options for the restore paths. Neither is a reason to skip the password manager, since both only help while you still have the device.
Next comes Set up your agent harnesses, where Buzz scans for command-line harnesses on this machine. Read the status text rather than assuming an installed tool is usable: a harness can report CLI detected; ACP adapter missing, which means Buzz found the tool but cannot drive it yet. Skip for now moves past this screen with nothing connected, and Settings → Agents does the same job later, so there is no need to solve it here.
Finally you need your public key, and there are two places to get it in two different encodings. That trips people up, so it is worth being precise:
- Settings → Profile → Identity details → Public key gives you the key as 64 hex characters, with a Copy Public key button next to it. This is the form the relay config wants, so it is the one to copy for Step 5. The section is collapsed by default — click the Identity details header to open it.
- If the relay refuses you for not being a member, that screen shows the same key
as an
npub1…string under “Your public key (npub)”. That is the form to send whoever administers the relay.
There is no “Settings → Identity” section; the identity rows live inside Profile, described there as “Your keypair and NIP-05 handle are fixed for this device.” Both encodings name the same key:
5d3b871484539…
npub1t5acw9yy2…
Every key shown in this article is a throwaway generated for the walkthrough and
belongs to nobody. That is worth stating because a public key is still a real
address: an npub copied from a blog post is somebody’s actual identity, and an
example is exactly the kind of value that gets pasted into a config without a
second thought. Use your own key everywhere one appears here.
Two kinds of key, two prefixes, and the difference matters:
| Prefix | What it is | Where it goes |
|---|---|---|
nsec1… | Secret key. Is your account. | Password manager, nowhere else |
npub1… | Public key, bech32. Your address. | Shared freely with people |
| 64 hex | The same public key, unencoded. | RELAY_OWNER_PUBKEY in the relay config |
Copy the hex form from Settings → Profile. Step 5 needs exactly that.
Step 4: Build a preflight tool
Copying a key between two encodings is the kind of thing that goes wrong
silently. RELAY_OWNER_PUBKEY takes 64 hex characters, and the relay starts
happily with a malformed value, never recognizes you as the owner, and says
nothing about it. If you were handed an npub1… by a teammate, or copied from
the join screen rather than the Profile panel, you need the conversion — and you
want it checked rather than eyeballed.
That is reason enough for a small tool, but there is a better one. A Buzz relay
binds each incoming connection to a community using the HTTP Host header, and
it does this before the WebSocket upgrade. The value it matches against is
the authority of RELAY_URL — hostname and port, since only :80 and
:443 are treated as defaults and stripped. So a relay configured with
RELAY_URL=ws://localhost:3000 binds its community to localhost:3000, and a
client that dials http://127.0.0.1:3000 gets a perfectly healthy response from
every health endpoint and the NIP-11 document, then a bare 404 on the upgrade.
Every check you would think to run passes. The app just spins.
This tool checks the upgrade itself, so that failure arrives as a sentence.
Scaffold the project and its .gitignore
The .gitignore goes in before anything else, because the next few steps put a
relay secret key and a set of generated passwords inside this directory tree.
Create the files
mkdir -p ~/buzz-preflight/src ~/buzz-preflight/tests
cd ~/buzz-preflight
touch .gitignore
Add the code: .gitignore
# Secrets — a Nostr secret key is the whole account, so never commit one
.env
*.env
owner-key.txt
# Python
__pycache__/
*.py[cod]
.venv/
# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
# OS / editor noise
.DS_Store
*.log
Detailed breakdown
.envand*.envcover both this project and the relay’sdeploy/compose/.env, which holds the Postgres password, the Redis password, the S3 credentials, and the relay’s own signing key.owner-key.txtis a named trap. If you ever spill yournsecto a file while debugging, this stops the reflexivegit add .from publishing it..venv/keepsuv’s virtualenv out of the repo; it is rebuilt fromuv.lockon any machine.
Initialize with uv
Create the file
cd ~/buzz-preflight
echo "3.11" > .python-version
touch pyproject.toml
Add the code: pyproject.toml
[project]
name = "buzz-relay-preflight"
version = "0.1.0"
description = "Preflight checks for a self-hosted Buzz relay on macOS"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"coincurve>=21.0.0",
"httpx>=0.28.1",
"websockets>=15.0",
]
[dependency-groups]
dev = [
"pytest>=8.3.0",
]
Detailed breakdown
coincurveis a libsecp256k1 binding. It is needed forsign_schnorr, because NIP-42 authentication requires a BIP-340 Schnorr signature — not the ECDSA signature most crypto libraries hand you by default.httpxcovers the health endpoints and the NIP-11 document.websocketsis what makes the community-binding check possible; the failure only manifests during a real WebSocket upgrade.requires-python = ">=3.11"is what allowsX | Noneannotations andtomllibwithout a compatibility shim.
Add the checker
Create the file
cd ~/buzz-preflight
touch src/preflight.py
Add the code: src/preflight.py
"""Preflight checks for a self-hosted Buzz relay.
Run this before you point the Buzz desktop app at a relay. It answers the four
questions that account for most "the app just spins" reports:
1. Is the relay process alive and ready?
2. Does it advertise the NIPs the desktop client needs?
3. Does the host you are dialing resolve to a provisioned community? The relay
binds a connection to a community using the HTTP ``Host`` header *before* the
WebSocket upgrade, matching it against the authority of ``RELAY_URL`` — host
plus port, since only ``:80`` and ``:443`` are treated as default. So a URL
that differs only in hostname (``127.0.0.1`` instead of ``localhost``) or that
drops the port is rejected with a bare 404 and no explanation.
4. Does the owner key in ``.env`` actually authenticate against the relay?
Every check is independent, so a failure early on does not hide later results.
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlparse, urlunparse
import httpx
import websockets
from coincurve import PrivateKey
# NIP-42 client authentication event.
KIND_CLIENT_AUTH = 22242
# NIPs the Buzz desktop client relies on: NIP-01 (core protocol), NIP-42
# (relay authentication), and NIP-50 (search).
REQUIRED_NIPS = (1, 42, 50)
# NIP-43 (relay membership). The relay advertises this one *conditionally* —
# only when membership enforcement is enabled AND a stable relay signing key is
# configured, because kind 13534/8000/8001 roster events are verified against
# the relay's own key. Unlike `auth_required`/`restricted_writes`, which are
# hardcoded true in the relay's NIP-11 document and therefore prove nothing,
# its presence is real evidence that the relay is closed.
NIP_RELAY_MEMBERSHIP = 43
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
# --------------------------------------------------------------------------
# Pure helpers — no I/O, so they are cheap to unit test.
# --------------------------------------------------------------------------
def _bech32_polymod(values: list[int]) -> int:
generator = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
checksum = 1
for value in values:
top = checksum >> 25
checksum = ((checksum & 0x1FFFFFF) << 5) ^ value
for i in range(5):
checksum ^= generator[i] if ((top >> i) & 1) else 0
return checksum
def _bech32_hrp_expand(hrp: str) -> list[int]:
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
def _convertbits(data: list[int], frm: int, to: int, pad: bool) -> list[int]:
"""Regroup a bit stream from ``frm``-bit groups into ``to``-bit groups."""
acc = 0
bits = 0
out: list[int] = []
maxv = (1 << to) - 1
for value in data:
if value < 0 or (value >> frm):
raise ValueError("value out of range for the source bit width")
acc = (acc << frm) | value
bits += frm
while bits >= to:
bits -= to
out.append((acc >> bits) & maxv)
if pad:
if bits:
out.append((acc << (to - bits)) & maxv)
elif bits >= frm or ((acc << (to - bits)) & maxv):
raise ValueError("invalid padding in the bit stream")
return out
def bech32_decode(text: str) -> tuple[str, bytes]:
"""Decode a bech32 string into its human-readable part and payload bytes."""
if text != text.lower() and text != text.upper():
raise ValueError("bech32 strings must not mix upper and lower case")
text = text.lower()
pos = text.rfind("1")
if pos < 1 or pos + 7 > len(text):
raise ValueError("missing or misplaced bech32 separator")
hrp, data_part = text[:pos], text[pos + 1 :]
try:
data = [BECH32_CHARSET.index(c) for c in data_part]
except ValueError as exc:
raise ValueError("invalid bech32 character") from exc
if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1:
raise ValueError("bech32 checksum mismatch")
return hrp, bytes(_convertbits(data[:-6], 5, 8, pad=False))
def bech32_encode(hrp: str, payload: bytes) -> str:
"""Encode ``payload`` as a bech32 string under ``hrp``. Used by the tests."""
data = _convertbits(list(payload), 8, 5, pad=True)
checksum_input = _bech32_hrp_expand(hrp) + data + [0, 0, 0, 0, 0, 0]
polymod = _bech32_polymod(checksum_input) ^ 1
checksum = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
return hrp + "1" + "".join(BECH32_CHARSET[d] for d in data + checksum)
def normalize_key(value: str, *, expect_hrp: str) -> str:
"""Return a 64-character lowercase hex key from hex or bech32 input.
The desktop app shows identities as ``npub1…``/``nsec1…`` while
``deploy/compose/.env`` wants raw hex, so this conversion is the single most
common place to get a self-hosted relay wrong.
"""
value = value.strip()
if not value:
raise ValueError("key is empty")
if value.startswith(f"{expect_hrp}1"):
hrp, payload = bech32_decode(value)
if hrp != expect_hrp:
raise ValueError(f"expected an {expect_hrp}… key, got {hrp}…")
if len(payload) != 32:
raise ValueError(f"expected 32 payload bytes, got {len(payload)}")
return payload.hex()
lowered = value.lower()
if len(lowered) != 64 or any(c not in "0123456789abcdef" for c in lowered):
raise ValueError("expected 64 hex characters or a bech32 key")
return lowered
def parse_env_file(text: str) -> dict[str, str]:
"""Parse the ``KEY=value`` lines of a compose ``.env`` file."""
env: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
env[key.strip()] = value.strip().strip('"').strip("'")
return env
def find_placeholders(env: dict[str, str]) -> list[str]:
"""Return the keys whose values still carry a ``CHANGE_ME`` placeholder."""
return sorted(k for k, v in env.items() if "CHANGE_ME" in v)
def event_id(event: dict) -> str:
"""Compute the NIP-01 event id: sha256 of a canonical JSON serialization."""
serialized = json.dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
],
separators=(",", ":"),
ensure_ascii=False,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def relay_authority(url: str) -> str:
"""Return the community-selecting authority of a relay URL.
This mirrors the relay's own ``normalize_host``: lowercase, strip a trailing
FQDN dot, and strip the port **only** when it is the scheme's default (80 or
443). Every other port stays part of the authority, which is why a relay
reached on :3000 binds the community to ``localhost:3000`` and not
``localhost``.
"""
parts = urlparse(url if "//" in url else f"//{url}")
host = (parts.hostname or "").strip().lower().rstrip(".")
port = parts.port
if port is None or port in (80, 443):
return host
return f"{host}:{port}"
def websocket_url(relay_url: str) -> str:
"""Map an http(s) relay URL to its ws(s) equivalent, preserving the host.
The hostname is deliberately left untouched. Rewriting ``localhost`` to
``127.0.0.1`` here would defeat the very check this tool exists to perform.
"""
parts = urlparse(relay_url)
scheme = {"http": "ws", "https": "wss"}.get(parts.scheme, parts.scheme)
return urlunparse(parts._replace(scheme=scheme))
# --------------------------------------------------------------------------
# Check results
# --------------------------------------------------------------------------
@dataclass
class Result:
name: str
ok: bool
detail: str
data: dict = field(default_factory=dict)
def _ok(name: str, detail: str, **data) -> Result:
return Result(name, True, detail, data)
def _fail(name: str, detail: str, **data) -> Result:
return Result(name, False, detail, data)
# --------------------------------------------------------------------------
# Checks that talk to the relay
# --------------------------------------------------------------------------
def check_env(env_path: Path | None, relay_url: str) -> list[Result]:
"""Validate the compose ``.env`` without ever printing a secret."""
if env_path is None:
return []
if not env_path.is_file():
return [_fail("env", f"no .env at {env_path}")]
env = parse_env_file(env_path.read_text(encoding="utf-8"))
results = []
placeholders = find_placeholders(env)
if placeholders:
results.append(
_fail(
"env placeholders",
f"{len(placeholders)} value(s) still CHANGE_ME: {', '.join(placeholders)}",
keys=placeholders,
)
)
else:
results.append(_ok("env placeholders", "no CHANGE_ME values remain"))
owner = env.get("RELAY_OWNER_PUBKEY", "")
if not owner:
results.append(_fail("owner pubkey", "RELAY_OWNER_PUBKEY is not set"))
else:
try:
normalized = normalize_key(owner, expect_hrp="npub")
except ValueError as exc:
results.append(_fail("owner pubkey", f"RELAY_OWNER_PUBKEY invalid: {exc}"))
else:
note = "" if normalized == owner.lower() else " (converted from npub)"
results.append(
_ok("owner pubkey", f"valid 64-hex key{note}", pubkey=normalized)
)
# RELAY_URL — not BUZZ_DOMAIN — is what the relay turns into the community
# host. BUZZ_DOMAIN is consumed only by the optional Caddy TLS profile, so
# comparing against it would pass while the client still got a 404.
configured = env.get("RELAY_URL", "")
if not configured:
results.append(_fail("relay URL", "RELAY_URL is not set; no community will be provisioned"))
else:
want = relay_authority(configured)
got = relay_authority(relay_url)
if want == got:
results.append(_ok("relay URL", f"dialing {got}, which matches RELAY_URL"))
else:
results.append(
_fail(
"relay URL",
f"you are dialing {got!r} but RELAY_URL declares {want!r}; "
f"the relay binds its community to {want!r} and will refuse "
"the WebSocket upgrade with a bare 404",
expected=want,
actual=got,
)
)
return results
def check_http(relay_url: str, timeout: float) -> list[Result]:
"""Probe the relay's health endpoints and its NIP-11 information document."""
results = []
base = relay_url.rstrip("/")
with httpx.Client(timeout=timeout) as client:
for path, name in (("/_liveness", "liveness"), ("/_readiness", "readiness")):
try:
response = client.get(f"{base}{path}")
except httpx.HTTPError as exc:
results.append(_fail(name, f"{type(exc).__name__}: {exc}"))
continue
body = response.text.strip()
if response.status_code == 200:
results.append(_ok(name, f"200 {body[:60]}"))
else:
results.append(_fail(name, f"HTTP {response.status_code} {body[:60]}"))
try:
response = client.get(
f"{base}/", headers={"Accept": "application/nostr+json"}
)
doc = response.json()
except (httpx.HTTPError, ValueError) as exc:
results.append(_fail("nip-11 document", f"{type(exc).__name__}: {exc}"))
return results
software = doc.get("software", "?")
version = doc.get("version", "?")
results.append(
_ok(
"nip-11 document",
f"{doc.get('name', '?')} · {software} · v{version}",
version=version,
)
)
supported = set(doc.get("supported_nips") or [])
missing = [nip for nip in REQUIRED_NIPS if nip not in supported]
if missing:
results.append(
_fail(
"required nips",
f"relay does not advertise NIP(s) {missing}",
missing=missing,
)
)
else:
results.append(
_ok("required nips", f"NIP {', '.join(str(n) for n in REQUIRED_NIPS)} present")
)
if NIP_RELAY_MEMBERSHIP in supported:
results.append(
_ok("membership enforced", "NIP-43 advertised: roster gating is on and the relay key is stable")
)
else:
results.append(
_fail(
"membership enforced",
"NIP-43 not advertised — the relay is not enforcing its member "
"roster, or BUZZ_RELAY_PRIVATE_KEY is unset. Anyone who can "
"authenticate can read. Set BUZZ_REQUIRE_RELAY_MEMBERSHIP=true "
"and a stable BUZZ_RELAY_PRIVATE_KEY.",
)
)
return results
async def _ws_probe(ws_url: str, secret_hex: str | None, timeout: float) -> list[Result]:
results = []
try:
async with websockets.connect(ws_url, open_timeout=timeout) as socket:
raw = await asyncio.wait_for(socket.recv(), timeout=timeout)
frame = json.loads(raw)
if frame[0] != "AUTH":
results.append(
_fail("community binding", f"expected AUTH, got {frame[0]}")
)
return results
challenge = frame[1]
results.append(
_ok("community binding", "host resolved to a community; AUTH challenge received")
)
if secret_hex is None:
return results
key = PrivateKey(bytes.fromhex(secret_hex))
event = {
"pubkey": key.public_key.format(compressed=True).hex()[2:],
"created_at": int(time.time()),
"kind": KIND_CLIENT_AUTH,
"tags": [["relay", ws_url], ["challenge", challenge]],
"content": "",
}
event["id"] = event_id(event)
event["sig"] = key.sign_schnorr(bytes.fromhex(event["id"])).hex()
await socket.send(json.dumps(["AUTH", event]))
raw = await asyncio.wait_for(socket.recv(), timeout=timeout)
reply = json.loads(raw)
if reply[0] != "OK" or not reply[2]:
results.append(
_fail("owner auth", f"relay rejected AUTH: {reply[3] if len(reply) > 3 else reply}")
)
return results
results.append(_ok("owner auth", "NIP-42 AUTH accepted"))
await socket.send(json.dumps(["REQ", "preflight", {"kinds": [1], "limit": 1}]))
while True:
reply = json.loads(await asyncio.wait_for(socket.recv(), timeout=timeout))
if reply[0] == "EOSE":
results.append(_ok("authorized read", "subscription reached EOSE"))
break
if reply[0] in ("CLOSED", "NOTICE"):
results.append(_fail("authorized read", f"{reply[0]}: {reply[-1]}"))
break
except (OSError, asyncio.TimeoutError, websockets.WebSocketException) as exc:
detail = f"{type(exc).__name__}: {exc}"
if "404" in detail:
detail += (
" — the relay has no community for this authority. Dial exactly the"
" host and port in RELAY_URL (the port counts unless it is 80 or 443)."
)
results.append(_fail("community binding", detail))
return results
def check_websocket(relay_url: str, secret_hex: str | None, timeout: float) -> list[Result]:
return asyncio.run(_ws_probe(websocket_url(relay_url), secret_hex, timeout))
# --------------------------------------------------------------------------
# Entry point
# --------------------------------------------------------------------------
def run_checks(
relay_url: str,
env_path: Path | None,
owner_secret: str | None,
timeout: float,
) -> list[Result]:
secret_hex = None
results: list[Result] = []
if owner_secret:
try:
secret_hex = normalize_key(owner_secret, expect_hrp="nsec")
except ValueError as exc:
results.append(_fail("owner secret", f"could not read secret key: {exc}"))
results.extend(check_env(env_path, relay_url))
results.extend(check_http(relay_url, timeout))
results.extend(check_websocket(relay_url, secret_hex, timeout))
return results
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Preflight a self-hosted Buzz relay before joining it."
)
parser.add_argument(
"--relay",
default="http://localhost:3000",
help="relay base URL (default: http://localhost:3000)",
)
parser.add_argument(
"--env",
type=Path,
default=None,
help="path to deploy/compose/.env to validate",
)
parser.add_argument(
"--owner-secret-env",
metavar="VAR",
default="BUZZ_OWNER_SECRET",
help="name of the environment variable holding the owner nsec or 64-hex "
"secret key (default: BUZZ_OWNER_SECRET). When that variable is set, the "
"NIP-42 auth check runs. The key is read from the environment rather than "
"an argument so it never appears in `ps` output or shell history.",
)
parser.add_argument("--timeout", type=float, default=10.0, help="per-request timeout")
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
args = parser.parse_args(argv)
owner_secret = os.environ.get(args.owner_secret_env) or None
results = run_checks(args.relay, args.env, owner_secret, args.timeout)
if args.json:
print(
json.dumps(
{
"relay": args.relay,
"ok": all(r.ok for r in results),
"checks": [
{"name": r.name, "ok": r.ok, "detail": r.detail, **r.data}
for r in results
],
},
indent=2,
)
)
else:
print(f"Buzz relay preflight — {args.relay}\n")
for result in results:
print(f" {'PASS' if result.ok else 'FAIL'} {result.name:<20} {result.detail}")
failures = [r for r in results if not r.ok]
print()
print(
f"{len(results) - len(failures)}/{len(results)} checks passed"
if failures
else f"All {len(results)} checks passed."
)
return 1 if any(not r.ok for r in results) else 0
if __name__ == "__main__":
sys.exit(main())
Detailed breakdown
- The bech32 block (
_bech32_polymod,_bech32_hrp_expand,_convertbits,bech32_decode) is a direct implementation of BIP-173 as NIP-19 uses it. It is ~50 lines and has no dependencies, which beats pulling a Nostr SDK for one conversion.bech32_decodeverifies the checksum, so a key mistyped by one character is rejected rather than silently decoding to the wrong 32 bytes. _convertbitswithpad=Falseis the strict path used for decoding. It rejects a payload whose leftover bits are non-zero, which is what catches truncated keys. Note that this strictness means the decoder only accepts plain 32-byte NIP-19 keys — a segwit address, which carries a witness-version prefix, will correctly fail here.normalize_keyis the function this whole file was worth writing for. It accepts either form and returns hex. Theexpect_hrpkeyword is a safety interlock: asking for annpuband being handed annsecraises rather than quietly writing your secret key into a config file that is about to be read by a container.event_idimplements the NIP-01 canonical serialization: a six-element JSON array with no whitespace and no ASCII escaping, hashed with SHA-256.separators=(",", ":")andensure_ascii=Falseare both load-bearing — either default would produce a different hash and every signature would be rejected.websocket_urldeliberately does not normalize the hostname. Helpfully resolvinglocalhostto127.0.0.1would mask exactly the failure this tool exists to find. There is a unit test pinning that behavior so a future cleanup does not reintroduce it.check_envreports on the config without printing a single secret value — it names the keys that still hold placeholders and echoes only the derived public key.check_httptreats liveness, readiness, and NIP-11 as independent, so one failure does not mask the others.- The
membership enforcedcheck looks for NIP-43, and that choice matters. The obvious thing to test is the NIP-11limitationobject’sauth_requiredandrestricted_writesflags — and it would be worthless, because the relay hardcodes both totrueregardless of configuration. A check that cannot fail proves nothing while looking reassuring, which is worse than no check at all. NIP-43 is advertised conditionally: only when membership enforcement is on and a stableBUZZ_RELAY_PRIVATE_KEYis set, because the roster events are verified against the relay’s own key. You can confirm the difference yourself by settingBUZZ_REQUIRE_RELAY_MEMBERSHIP=falseand restarting —43vanishes fromsupported_nipswhileauth_requiredstaystrue. _ws_probeis the heart of it. Receiving["AUTH", <challenge>]as the first frame proves theHostheader bound to a community; that frame never arrives if it did not. When a secret key is supplied it goes on to build a kind22242event, Schnorr-sign it, and send it back — then issues aREQand waits forEOSEto prove the identity is on the membership roster, not merely syntactically valid.sign_schnorr, notsign. Nostr is BIP-340 throughout. An ECDSA signature here produces a valid-looking event that every relay rejects.- The
404special case in the exception handler turns the least informative error in the whole system into an instruction.
Add the tests
The checks that talk to a relay need a relay, but everything that decides whether those checks are correct is pure. Those get tested against published NIP-19 vectors, with no network involved.
Create the file
cd ~/buzz-preflight
touch tests/test_preflight.py
Add the code: tests/test_preflight.py
"""Unit tests for the pure helpers in src/preflight.py.
These run with no relay and no network. The parts that talk to a relay are
exercised by pointing `make preflight` at a running stack.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from preflight import ( # noqa: E402
bech32_decode,
bech32_encode,
event_id,
find_placeholders,
normalize_key,
parse_env_file,
relay_authority,
websocket_url,
)
# The public half of the example keypair published in the NIP-19 specification.
# An external vector is the point: a bech32 decoder that is subtly wrong still
# returns 32 plausible bytes, so checking it against your own output proves
# nothing. Never substitute a real identity here — a well-known npub is still
# somebody's address, and an example is exactly what a reader pastes without
# thinking.
NPUB = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"
NPUB_HEX = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
def throwaway_keypair() -> tuple[str, str, str]:
"""Generate a keypair for one test run: (secret_hex, nsec, npub).
The secret-key paths are exercised with a key generated here rather than the
specification's example `nsec`. Both are equally harmless — the spec's
private key is deliberately published — but a tutorial that prints a full
`nsec1…` teaches that printing secret keys is normal, and it is not.
"""
from coincurve import PrivateKey
key = PrivateKey(os.urandom(32))
secret = key.secret.hex()
pubkey = key.public_key.format(compressed=True).hex()[2:]
return secret, bech32_encode("nsec", bytes.fromhex(secret)), bech32_encode(
"npub", bytes.fromhex(pubkey)
)
class TestBech32:
def test_decodes_published_npub_vector(self):
hrp, payload = bech32_decode(NPUB)
assert hrp == "npub"
assert payload.hex() == NPUB_HEX
def test_round_trips_a_generated_nsec(self):
secret, nsec, _ = throwaway_keypair()
hrp, payload = bech32_decode(nsec)
assert hrp == "nsec"
assert payload.hex() == secret
def test_round_trips(self):
assert bech32_encode("npub", bytes.fromhex(NPUB_HEX)) == NPUB
def test_rejects_a_corrupted_checksum(self):
corrupted = NPUB[:-1] + ("q" if NPUB[-1] != "q" else "p")
with pytest.raises(ValueError, match="checksum"):
bech32_decode(corrupted)
def test_rejects_mixed_case(self):
with pytest.raises(ValueError, match="mix upper and lower"):
bech32_decode(NPUB[:10].upper() + NPUB[10:])
def test_bech32_agrees_with_secp256k1(self):
"""A key encoded as `nsec` must decode to a secret deriving that `npub`.
This ties the bech32 layer to secp256k1, so an encoder and decoder that
are wrong in mirror-image ways cannot satisfy both halves by accident.
A fresh keypair each run covers more of the space than one fixed vector.
"""
from coincurve import PrivateKey
secret, nsec, npub = throwaway_keypair()
recovered = bech32_decode(nsec)[1]
assert recovered.hex() == secret
derived = PrivateKey(recovered).public_key.format(compressed=True).hex()[2:]
assert derived == bech32_decode(npub)[1].hex()
class TestNormalizeKey:
def test_converts_npub_to_hex(self):
assert normalize_key(NPUB, expect_hrp="npub") == NPUB_HEX
def test_passes_hex_through_lowercased(self):
assert normalize_key(NPUB_HEX.upper(), expect_hrp="npub") == NPUB_HEX
def test_tolerates_surrounding_whitespace(self):
assert normalize_key(f" {NPUB}\n", expect_hrp="npub") == NPUB_HEX
def test_rejects_an_nsec_where_an_npub_is_required(self):
# Guards the worst possible paste: a secret key into RELAY_OWNER_PUBKEY.
_, nsec, _ = throwaway_keypair()
with pytest.raises(ValueError):
normalize_key(nsec, expect_hrp="npub")
def test_rejects_a_truncated_hex_key(self):
with pytest.raises(ValueError, match="64 hex characters"):
normalize_key(NPUB_HEX[:-2], expect_hrp="npub")
def test_rejects_non_hex_characters(self):
with pytest.raises(ValueError, match="64 hex characters"):
normalize_key("z" * 64, expect_hrp="npub")
def test_rejects_empty_input(self):
with pytest.raises(ValueError, match="empty"):
normalize_key(" ", expect_hrp="npub")
class TestParseEnvFile:
SAMPLE = """
# Buzz production environment
BUZZ_DOMAIN=localhost
RELAY_URL=ws://localhost:3000
POSTGRES_PASSWORD="quoted-secret"
REDIS_PASSWORD='single-quoted'
RELAY_OWNER_PUBKEY=CHANGE_ME_OWNER_PUBKEY_HEX
# commented=out
malformed-line-without-equals
"""
def test_reads_plain_values(self):
env = parse_env_file(self.SAMPLE)
assert env["BUZZ_DOMAIN"] == "localhost"
assert env["RELAY_URL"] == "ws://localhost:3000"
def test_strips_surrounding_quotes(self):
env = parse_env_file(self.SAMPLE)
assert env["POSTGRES_PASSWORD"] == "quoted-secret"
assert env["REDIS_PASSWORD"] == "single-quoted"
def test_skips_comments_and_malformed_lines(self):
env = parse_env_file(self.SAMPLE)
assert "commented" not in env
assert "malformed-line-without-equals" not in env
def test_finds_remaining_placeholders(self):
assert find_placeholders(parse_env_file(self.SAMPLE)) == ["RELAY_OWNER_PUBKEY"]
def test_reports_no_placeholders_once_filled_in(self):
env = parse_env_file(self.SAMPLE.replace("CHANGE_ME_OWNER_PUBKEY_HEX", NPUB_HEX))
assert find_placeholders(env) == []
class TestEventId:
def test_matches_the_id_of_a_known_signed_event(self):
# A real NIP-01 event; its id is the sha256 of the canonical form.
event = {
"pubkey": NPUB_HEX,
"created_at": 1_700_000_000,
"kind": 1,
"tags": [],
"content": "hello",
}
computed = event_id(event)
assert len(computed) == 64
# Recomputing is stable, and any field change moves the id.
assert computed == event_id(dict(event))
assert computed != event_id({**event, "content": "hello "})
def test_tags_participate_in_the_id(self):
base = {
"pubkey": NPUB_HEX,
"created_at": 1_700_000_000,
"kind": 22242,
"tags": [["challenge", "abc"]],
"content": "",
}
other = {**base, "tags": [["challenge", "abd"]]}
assert event_id(base) != event_id(other)
class TestWebsocketUrl:
@pytest.mark.parametrize(
("http_url", "expected"),
[
("http://localhost:3000", "ws://localhost:3000"),
("https://buzz.example.com", "wss://buzz.example.com"),
("http://localhost:3000/", "ws://localhost:3000/"),
],
)
def test_maps_scheme(self, http_url, expected):
assert websocket_url(http_url) == expected
def test_preserves_the_hostname_verbatim(self):
# Rewriting localhost to 127.0.0.1 would break the community binding
# this tool exists to check, so the host must survive untouched.
assert websocket_url("http://127.0.0.1:3000") == "ws://127.0.0.1:3000"
assert "localhost" in websocket_url("http://localhost:3000")
class TestRelayAuthority:
"""The authority is what the relay matches a connection's Host against.
Verified against the running relay: with RELAY_URL=ws://localhost:3000,
`Host: localhost:3000` upgrades (101) while `Host: localhost` and
`Host: 127.0.0.1:3000` are both refused (404). So the port is part of the
identity unless it is a scheme default.
"""
@pytest.mark.parametrize(
("url", "expected"),
[
("ws://localhost:3000", "localhost:3000"),
("http://localhost:3000", "localhost:3000"),
("https://buzz.example.com", "buzz.example.com"),
("wss://buzz.example.com", "buzz.example.com"),
# Default ports are stripped, mirroring the relay's normalize_host.
("http://buzz.example.com:80", "buzz.example.com"),
("https://buzz.example.com:443", "buzz.example.com"),
# A non-default port is retained even on a public host.
("https://buzz.example.com:8443", "buzz.example.com:8443"),
# Case and a trailing FQDN dot are normalized away.
("http://LocalHost.:3000", "localhost:3000"),
],
)
def test_authority(self, url, expected):
assert relay_authority(url) == expected
def test_distinguishes_localhost_from_loopback_ip(self):
# These are the same machine but NOT the same community.
assert relay_authority("http://localhost:3000") != relay_authority(
"http://127.0.0.1:3000"
)
def test_port_is_part_of_the_identity(self):
assert relay_authority("http://localhost:3000") != relay_authority(
"http://localhost"
)
Detailed breakdown
- The NIP-19 vector is the whole point of
TestBech32. A bech32 implementation that is subtly wrong still returns 32 plausible bytes, so testing it against your own output proves nothing.NPUB/NPUB_HEXcome from the specification, which is an authority this code did not write. - Only the public half is hardcoded. The specification also publishes a
matching example
nsec, and it would be perfectly safe to use — its private key is deliberately public. It is left out anyway, because an article that prints a fullnsec1…teaches that printing secret keys is routine. The secret-key paths usethrowaway_keypair()instead, which generates a fresh key per run and, being randomized, covers more of the space than one fixed vector would. test_bech32_agrees_with_secp256k1is what keeps the suite non-circular on the secret side: it encodes a generated key as annsec, decodes it back, and checks the recovered secret derives the samenpub. An encoder and decoder wrong in mirror-image ways would pass a round-trip test but fail this one.test_rejects_an_nsec_where_an_npub_is_requiredpins the interlock that prevents a secret key from being written intoRELAY_OWNER_PUBKEY. It is the single most damaging paste available in this workflow.test_reports_no_placeholders_once_filled_inguards against the check being vacuously true — afind_placeholdersthat always returned[]would pass the negative test but fail this one.TestEventIddoes not hardcode a hash. It asserts the properties that matter — stable across recomputation, sensitive to content, sensitive to tags — because the trailing-space case and the tag case are the two ways a hand-rolled serialization goes wrong.test_preserves_the_hostname_verbatimexists purely to stop a future refactor from “fixing”websocket_urlinto uselessness.sys.path.insertkeeps the layout flat. For a single-module tool this beats adding packaging config to make an import work.
Add the Makefile
Create the file
cd ~/buzz-preflight
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
RELAY ?= http://localhost:3000
ENV_FILE ?= $(HOME)/buzz/deploy/compose/.env
.PHONY: help preflight preflight-auth test relay-up relay-down relay-logs members clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
preflight: ## Check the relay (override RELAY=... ENV_FILE=...)
uv run python src/preflight.py --relay $(RELAY) --env $(ENV_FILE)
preflight-auth: ## Also verify the owner key authenticates (reads BUZZ_OWNER_SECRET)
@: $${BUZZ_OWNER_SECRET:?export BUZZ_OWNER_SECRET first}
uv run python src/preflight.py --relay $(RELAY) --env $(ENV_FILE)
test: ## Run the unit tests
uv run pytest -v
relay-up: ## Start the self-hosted relay stack
cd $(dir $(ENV_FILE)) && ./run.sh start
relay-down: ## Stop the relay stack, keeping volumes
cd $(dir $(ENV_FILE)) && ./run.sh stop
relay-logs: ## Follow the relay container logs
cd $(dir $(ENV_FILE)) && ./run.sh logs
members: ## List the relay membership roster
cd $(dir $(ENV_FILE)) && ./run.sh list-members
clean: ## Remove caches and test artifacts
rm -rf .pytest_cache .ruff_cache src/__pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes baremakeprint the help screen. Thehelptarget greps the Makefile for##comments, so a new target documents itself by being written.RELAY ?=andENV_FILE ?=use?=so both are overridable from the command line without editing the file:make preflight RELAY=https://buzz.example.com.@: $${BUZZ_OWNER_SECRET:?…}fails early with a readable message when the variable is unset, and the recipe below it passes no secret on the command line. This matters more than it looks: an earlier version of this Makefile expanded the variable into--owner-secret "<nsec>", which puts the key inargvwhere any user on the machine can read it out ofps. Keeping the secret in the environment and lettingpreflight.pyread it withos.environkeeps it out of both shell history and the process table.$(dir $(ENV_FILE))derives the compose directory from the.envpath, so the relay targets followENV_FILEautomatically..PHONYcovers every target, none of which produce a file of that name.
Run it
cd ~/buzz-preflight
uv sync
make test
============================== 23 passed in 0.02s ==============================
Bare make prints the help screen:
help Show this help screen
preflight Check the relay (override RELAY=... ENV_FILE=...)
preflight-auth Also verify the owner key authenticates (reads BUZZ_OWNER_SECRET)
test Run the unit tests
relay-up Start the self-hosted relay stack
relay-down Stop the relay stack, keeping volumes
relay-logs Follow the relay container logs
members List the relay membership roster
clean Remove caches and test artifacts
Now convert the npub you copied in Step 3 into the hex form the relay wants:
uv run python -c "
import sys; sys.path.insert(0, 'src')
from preflight import normalize_key
print(normalize_key('<paste your npub here>', expect_hrp='npub'))
"
5d3b871484539…
It prints the full 64-character string; keep it, because Step 5 writes it into the relay config. Keys are abridged throughout this article — yours will be the full length.
Step 5: Configure the relay
The Buzz repository ships two Compose files and they are not interchangeable. The
root docker-compose.yml is development infrastructure — it starts Postgres,
Redis, MinIO, Keycloak, Adminer, and Prometheus, and expects you to build and run
the relay yourself from source. The bundle in deploy/compose/ is the
single-node deployment: it runs a prebuilt relay image alongside its
dependencies, so no Rust toolchain is involved.
Use the second one. It is the difference between a ten-minute setup and a twenty-minute compile.
Create the file
git clone --depth 1 https://github.com/block/buzz.git ~/buzz
cd ~/buzz/deploy/compose
cp .env.example .env
The template ships seven CHANGE_ME assignments — six secrets plus your owner
key. Generating them by hand is error-prone, and run.sh refuses to start while
any remain, so generate them.
Substitute your own key on the first line before running this. It is the one value that cannot be generated, and pasting someone else’s leaves you with a relay whose roster looks correct and whose owner you are not — which surfaces later as the desktop app being refused, with nothing in the config obviously wrong:
cd ~/buzz/deploy/compose
OWNER_PUBKEY_HEX=<paste the 64-hex Public key from Settings → Profile>
uv run --no-project python - "$OWNER_PUBKEY_HEX" <<'PY'
import pathlib, secrets, sys
owner = sys.argv[1]
env = pathlib.Path(".env")
text = env.read_text()
replacements = {
"RELAY_OWNER_PUBKEY": owner,
"BUZZ_RELAY_PRIVATE_KEY": secrets.token_hex(32),
"BUZZ_GIT_HOOK_HMAC_SECRET": secrets.token_hex(32),
"POSTGRES_PASSWORD": secrets.token_hex(32),
"REDIS_PASSWORD": secrets.token_hex(32),
"BUZZ_S3_ACCESS_KEY": secrets.token_hex(12),
"BUZZ_S3_SECRET_KEY": secrets.token_hex(32),
# Local install: every URL must agree on the hostname "localhost".
"BUZZ_DOMAIN": "localhost",
"RELAY_URL": "ws://localhost:3000",
"BUZZ_MEDIA_BASE_URL": "http://localhost:3000/media",
"BUZZ_MEDIA_SERVER_DOMAIN": "localhost",
"BUZZ_CORS_ORIGINS": "http://localhost:3000",
}
lines = []
for line in text.splitlines():
key = line.split("=", 1)[0].strip()
lines.append(f"{key}={replacements[key]}" if key in replacements else line)
env.write_text("\n".join(lines) + "\n")
print("wrote .env")
PY
chmod 600 .env
Detailed breakdown
RELAY_OWNER_PUBKEYis your identity, and the relay reads it at first boot to seed the membership roster with you asowner. It is deliberately not prefixedBUZZ_;deploy/compose/README.mdcalls this out, and it is easy to “correct” into a variable nothing reads.BUZZ_RELAY_PRIVATE_KEYis the relay’s own identity, separate from yours. It signs relay-authored events, including the roster. Rotating it changes who those events appear to come from, so generate it once and back it up.RELAY_URLis the one that provisions the community. The relay derives the community’s host from its authority — hostname plus port, with only:80and:443stripped as defaults — and matches theHostheader of every later connection against it. WithRELAY_URL=ws://localhost:3000the community is bound tolocalhost:3000, so127.0.0.1:3000and even a barelocalhostare both refused. This is the source of the404described in Step 4.BUZZ_DOMAINis not that value, despite the name. Nothing in the relay reads it; it is consumed only by the optional Caddy TLS profile (compose.caddy.yml), which is why the generator above sets it consistently but you should not expect changing it alone to move the community.BUZZ_MEDIA_SERVER_DOMAINis read by nothing in the repository at all.BUZZ_REQUIRE_RELAY_MEMBERSHIP=trueis the one that closes the relay: only pubkeys on the roster are admitted.BUZZ_REQUIRE_AUTH_TOKEN=truegoverns the HTTP/NIP-98 bridge rather than reads — with it false, a dev-modeX-Pubkeyheader fallback activates. WebSocket read authentication is unconditional either way. Both are already set in the template and should stay.BUZZ_AUTO_MIGRATE=truelets the relay run its own schema migrations on first boot. Without it you would runbuzz-admin migrateby hand against an empty database.chmod 600because this file now holds five secrets plus the relay signing key. It is already covered by the repository’s.gitignore, but file permissions are the protection that survives being copied elsewhere.
Step 6: Start the relay
run.sh wraps docker compose with the right file set and refuses to start on a
config that still has placeholders. The first start pulls five images — about
1.2 GB on disk once unpacked — and waits for each service’s health check, so
expect a couple of minutes.
cd ~/buzz/deploy/compose
./run.sh config > /dev/null && echo "config OK"
./run.sh start
Compose interleaves Waiting, Starting, Started, and Healthy lines per
service as they settle. The tail is what matters:
Container buzz-prod-minio-init-1 Exited
Container buzz-prod-redis-1 Healthy
Container buzz-prod-postgres-1 Healthy
Container buzz-prod-minio-1 Healthy
Container buzz-prod-relay-1 Starting
Container buzz-prod-relay-1 Started
Container buzz-prod-relay-1 Waiting
Container buzz-prod-relay-1 Healthy
The relay is last to go healthy because it runs a git object-store conformance
probe at startup; on a cold start it can sit unhealthy for a minute before
flipping. That is expected, not a failure.
Confirm the owner seeding worked:
./run.sh list-members
Keys are abridged below. In a real terminal the columns are padded for full 64-character pubkeys, so rows run to about 160 characters and wrap unless the window is wide:
pubkey role added_by created_at
----------------------------------------------------------------------------------------------------------------------------------------------------------------
5d3b871484539… owner - 2026-08-22T01:44:28Z
Detailed breakdown
./run.sh configrenders the merged Compose configuration without starting anything. It fails on an unset required variable, which is a cheaper way to find a typo than watching a container crash-loop../run.sh startrunsdocker compose up -d --wait. The--waitis why the output reportsHealthyrather than merelyStarted— it blocks until every health check passes, so a successful return means the stack is actually serving.minio-initexits deliberately. It is a one-shot container that creates the media bucket and sets it non-public, then completes. AnExitedstatus for that service is correct, not a failure.list-membersshowing your pubkey with roleownerandadded_by -is the confirmation thatRELAY_OWNER_PUBKEYwas read and understood. If this table is empty, the value was malformed — the relay does not warn about it.The relay tells you the exact host it bound the community to. This is the single most useful line in the startup log, because it settles the question Step 7 is about without any guesswork:
./run.sh logs relay 2>&1 | grep "Deployment community ensured"{"timestamp":"2026-08-22T02:51:27.884756Z","level":"INFO","message":"Deployment community ensured","host":"localhost:3000","community":"dd1914a7-af1e-4a3b-892a-a38c6344d13f","target":"buzz_relay"}Note
"host":"localhost:3000"— port included. Whatever appears there is exactly what a client’sHostheader has to match.Useful neighbours:
./run.sh logsfollows the relay,./run.sh statusshows service state, and./run.sh stopshuts down while keeping volumes.
Step 7: Preflight before you join
The relay reports healthy, which is necessary and not sufficient. This is the point where running the checker saves the evening, because it tests the one thing Docker’s health check cannot: whether a client dialing your URL actually reaches a community.
cd ~/buzz-preflight
make preflight ENV_FILE=~/buzz/deploy/compose/.env
Buzz relay preflight — http://localhost:3000
PASS env placeholders no CHANGE_ME values remain
PASS owner pubkey valid 64-hex key
PASS relay URL dialing localhost:3000, which matches RELAY_URL
PASS liveness 200 ok
PASS readiness 200 {"status":"ready"}
PASS nip-11 document Buzz Relay · https://github.com/block/buzz · v0.2.1
PASS required nips NIP 1, 42, 50 present
PASS membership enforced NIP-43 advertised: roster gating is on and the relay key is stable
PASS community binding host resolved to a community; AUTH challenge received
All 9 checks passed.
To prove your key authenticates and not merely that the relay is reachable, add the secret. Read it from a password manager rather than typing it:
export BUZZ_OWNER_SECRET="$(op read 'op://Private/Buzz/nsec')" # or your manager's equivalent
make preflight-auth ENV_FILE=~/buzz/deploy/compose/.env
That adds two lines:
PASS owner auth NIP-42 AUTH accepted
PASS authorized read subscription reached EOSE
Now see what the failure this tool was written for actually looks like. The relay is running and healthy; only the hostname changes:
uv run python src/preflight.py --relay http://127.0.0.1:3000 --env ~/buzz/deploy/compose/.env
Buzz relay preflight — http://127.0.0.1:3000
PASS env placeholders no CHANGE_ME values remain
PASS owner pubkey valid 64-hex key
FAIL relay URL you are dialing '127.0.0.1:3000' but RELAY_URL declares 'localhost:3000'; the relay binds its community to 'localhost:3000' and will refuse the WebSocket upgrade with a bare 404
PASS liveness 200 ok
PASS readiness 200 {"status":"ready"}
PASS nip-11 document Buzz Relay · https://github.com/block/buzz · v0.2.1
PASS required nips NIP 1, 42, 50 present
PASS membership enforced NIP-43 advertised: roster gating is on and the relay key is stable
FAIL community binding InvalidStatus: server rejected WebSocket connection: HTTP 404 — the relay has no community for this authority. Dial exactly the host and port in RELAY_URL (the port counts unless it is 80 or 443).
7/9 checks passed
Detailed breakdown
Every HTTP-level diagnostic still passes on the wrong hostname. Liveness, readiness, NIP-11 — everything you would reach for by instinct is green, which is precisely why this is so expensive to debug by hand. Only the two checks that know what the relay binds against catch it.
The
relay URLcheck catches it before any connection is attempted, by comparing the authority you are dialing with the oneRELAY_URLdeclares. Thecommunity bindingcheck then confirms it against the live relay. Two failures for one cause is deliberate: the first tells you what is wrong, the second proves it.You can reproduce it with
curlalone. Ask for the status code explicitly on both requests:curl -s -o /dev/null -w 'liveness: %{http_code}\n' http://127.0.0.1:3000/_liveness curl -s -w '\nupgrade: %{http_code}\n' \ -H 'Host: 127.0.0.1:3000' -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ http://127.0.0.1:3000/liveness: 200 relay: no community is configured for this host upgrade: 404The message is deliberately vague on the relay’s side. It does not distinguish “no such community” from “lookup failed”, and it never echoes the host, so an unauthenticated caller cannot probe which communities a deployment hosts. Good security, hostile debugging, hence the tool.
The port counts too. Because only
:80and:443are stripped, a relay on:3000refuses a bareHost: localhostexactly as it refuses127.0.0.1. Confirm it against your own relay:for h in localhost:3000 localhost 127.0.0.1:3000; do printf '%-16s -> ' "$h" curl -s -o /dev/null -w '%{http_code}\n' -H "Host: $h" \ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ http://127.0.0.1:3000/ donelocalhost:3000 -> 101 localhost -> 404 127.0.0.1:3000 -> 404The same trap applies to a VPS, where it is easier to hit: if
RELAY_URL=wss://buzz.example.com, dialing the server’s IP address fails identically. Always dial the name inRELAY_URL.
Step 8: Join the community from the desktop app
With a relay that passes preflight, the app has something to connect to. The onboarding screen is headed Join or create a community and offers three choices: Join a community, Create a community, and I already have a community. If you are already past onboarding, the sidebar’s add button opens a dialog headed Join an existing community instead.
Take I already have a community. That leads to Reconnect to your community, which asks for your role — I own the community or I’m a member or admin. Counterintuitively, pick I’m a member or admin: that is the branch with a URL field. The field is labelled Invite link or code, but the prompt above it reads “Enter the community URL or an invite link”, and a plain relay URL is what it wants:
ws://localhost:3000
“Create a community” uses Buzz’s hosted infrastructure. That is a fine way to try the product, but it is not what you just built — it puts your history on someone else’s relay.
Expect to be turned away the first time. Your relay is closed, and the
identity the app generated in Step 3 is not the owner key you put in .env
unless you deliberately made it so. The app connects, is refused, and shows:
MEMBERSHIP REQUIRED
Not a member yet
This relay requires an invitation. Ask a relay admin to add you as a member.
Your public key (npub)
npub1awphhhmme…
This is the system working. It is also the most convenient place to get your
npub, since the screen exists to hand it to an admin. Add it to the roster from
the relay side and click Try again:
cd ~/buzz/deploy/compose
./run.sh add-member <the npub from that screen> --role member
added eb837bdf7bc95… as member
Note that add-member echoes the hex form of the key you gave it as an npub —
a free confirmation that the two encodings match.
You are then asked to Build your profile (a display name and optional avatar), shown Meet your starter team — the three built-in agents Fizz, Honey, and Pollen — and Take me to Buzz loads the workspace. The relay log confirms the round trip:
{"level":"INFO","message":"NIP-42 auth successful","pubkey":"eb837bdf7bc95fb…"}
If instead you seeded RELAY_OWNER_PUBKEY with this app’s own key in Step 5, you
skip the refusal entirely and arrive as owner.
BUZZ_RELAY_URL only prefills the relay field on that setup screen. Once a
community has been joined, the app stores it as a workspace override that takes
precedence over the environment, so setting the variable will not repoint an
app that is already joined — the desktop source says as much in a comment:
“Public builds retain community selection even when BUZZ_RELAY_URL is
overridden at runtime.” Switch relays from inside the app instead. It is still
worth setting before a first launch:
BUZZ_RELAY_URL=ws://localhost:3000 open -a /Applications/Buzz.app
(open passes the environment through to the launched app, so this works the
same as launching the binary directly.)
Adding other people is a matter of adding their pubkey to the roster. Have them
send you the npub shown as “public ID” on their join screen, then:
cd ~/buzz/deploy/compose
./run.sh add-member npub1theirkeyhere --role member
add-member accepts either npub or hex, so no conversion is needed here — the
hex requirement is specific to RELAY_OWNER_PUBKEY in .env. When adding
several people, put a sleep 1 between calls; the roster is itself a signed
event and same-second writes collide.
Step 9: Connect an agent and drive it from the CLI
Buzz brings no models. Agents live in two places in the UI: Agents is a top-level sidebar item in the workspace, and Settings → Agents (under the “App” group) is where runtimes and defaults are configured. The settings panel describes itself as “Control how agents behave in conversations and run on this machine.”
Under Agent runtimes, Check again re-scans the machine. What you get is a status per runtime, and the statuses matter more than the presence of a row:
| Runtime | Status on a machine with Claude Code and Codex installed |
|---|---|
| Buzz Agent | Ready |
| OpenCode | Ready |
| Goose | CLI needed |
| Claude Code | Adapter needed |
| Codex | Adapter needed |
Detected is not the same as usable. Claude Code and Codex were both installed
and authenticated on the machine used here, and both still reported
Adapter needed — Buzz talks to a harness over ACP, and that adapter installs
separately, via the Install button beside the row. Budget for that step rather
than assuming an existing CLI is ready. Add runtimes extends the list beyond
the built-in five.
Below that, Agent defaults sets the provider, model, effort, and environment that local agents inherit, starting with a Default harness dropdown. Agent-specific settings always override these. With a harness chosen, create an agent and give it a name, a description that acts as its system prompt, and a model.
Be deliberate here, because the security model is broader than it first appears. An agent runs the whole harness on your machine, not just a model: its MCP servers, its skills, and its file access all come along. If your Claude Code install has a filesystem MCP server pointed at your home directory, an agent you add to a public channel can reach it. That is the feature — it is also the thing to think about before adding an agent to a channel with people you do not know.
Tagging is required. An agent that is merely present in a channel stays silent
until you @ it by name. The built-in agents ship with respond_to set to
owner-only, so out of the box they answer the person who owns them and nobody
else — worth knowing before you conclude an agent is broken because it ignored a
teammate.
For scripting, agent tooling, and CI, Buzz ships a CLI. It is not on Homebrew, so build it from the clone you already have:
cd ~/buzz
cargo install --path crates/buzz-cli --locked
The crate is buzz-cli but the binary it installs is buzz. It speaks JSON in
and JSON out, which is what makes it usable as an agent tool:
export BUZZ_RELAY_URL=http://localhost:3000
export BUZZ_PRIVATE_KEY="$(op read 'op://Private/Buzz/nsec')"
buzz channels create --name "lead-magnets" --type stream --visibility open
buzz channels list
{"accepted":true,"channel_id":"01c910ca-0689-4a80-8ad7-4b5045c1888a","event_id":"07cfc4719c83c698ea113555e767a94c14af08f38ab6bde86ae0f37bf1176855","message":""}
[{"channel_id":"01c910ca-0689-4a80-8ad7-4b5045c1888a","created_at":1787363269,"description":"","name":"lead-magnets"}]
Detailed breakdown
The CLI talks REST, not WebSocket. It uses the relay’s HTTP bridge with NIP-98 request signing, so
http://is the natural form. You do not have to be careful about this:normalize_relay_urlrewritesws://tohttp://andwss://tohttps://on the way in, precisely so a URL copied from an MCP config works unchanged.BUZZ_PRIVATE_KEYaccepts annsecdirectly, so no conversion is needed. Read it from a password manager; anything you type afterexportlands in your shell history.Errors are JSON on stderr as
{"error": "<category>", "message": "<detail>"}, with documented exit codes:0ok,1bad input,2relay/network error,3auth error,4other,5write conflict. That is what makes it safe to wire into a script.The closed relay is enforced here too. A key that is not on the roster gets a clean refusal rather than an empty result:
BUZZ_PRIVATE_KEY=$(openssl rand -hex 32) buzz channels list{"error":"auth_error","message":"relay error 403: relay_membership_required","retryable":false}There is no
--versionflag.buzz --versionreturns a JSONuser_error, which is startling the first time. Usebuzz --help, which lists twenty-odd command groups —messages,channels,canvas,reactions,emoji,dms,users,agents,workflows,feed,notes,media,moderation, the NIP-34 git set (repos,patches,issues,pr), andmemfor agent memory.
Troubleshooting
The app spins forever on “connecting”. Run make preflight against the exact
URL you gave the app. If relay URL or community binding fails, the authority
you dialed does not match the one in RELAY_URL; this accounts for most cases.
Match it exactly, including the port: localhost:3000 is a different community
from both 127.0.0.1:3000 and a bare localhost.
./run.sh start exits immediately with a message about CHANGE_ME. The
generator in Step 5 did not run, or ran against a different directory. Check with
the same pattern run.sh uses, which matches assignments and ignores the
reminder in the file’s header comment:
grep -En '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=.*CHANGE_ME' .env
A plain grep CHANGE_ME .env always reports that comment line and will send you
looking for a placeholder that is not there.
list-members is empty after a successful start. RELAY_OWNER_PUBKEY was
malformed. The relay does not validate it loudly. Re-derive the hex with
normalize_key, fix .env, and run ./run.sh restart.
You joined but everything is read-only, or channels do not appear. Your key
is not on the roster. Confirm with ./run.sh list-members that the pubkey listed
matches the Public key under Settings → Profile → Identity details.
cargo install fails on the workspace. rust-toolchain.toml pins 1.95.0,
which rustup installs on demand. Without rustup — or if the pin cannot be
satisfied — run . ./bin/activate-hermit from the repository root to pick up the
toolchain Hermit manages instead.
Port 3000 is already in use. Set BUZZ_HTTP_PORT in .env to a free port,
and update RELAY_URL, BUZZ_MEDIA_BASE_URL, and BUZZ_CORS_ORIGINS to match
it. BUZZ_HTTP_PORT moves only the host side of the mapping — compose publishes
${BUZZ_HTTP_PORT:-3000}:3000 and the relay still binds 3000 inside the
container. Because the port is part of the community authority, changing it
without changing RELAY_URL is exactly the Step 7 failure.
You want to start over. ./run.sh stop keeps your data.
docker compose --env-file .env -f compose.yml down -v destroys the volumes and
gives you an empty relay — your identity survives, because it lives in the app,
not the relay.
Recap
You installed a notarized Buzz desktop build and verified its signature rather
than trusting it. You generated a Nostr identity and learned which half of it can
be shared, and in which encoding. You stood up a complete relay (Rust binary,
Postgres, Redis, MinIO) from a prebuilt image with one command, seeded yourself
as its owner, and joined it from the app. Along the way you built a preflight
tool that converts between key encodings, validates the compose config without
printing a secret, proves the relay is actually enforcing its roster, and catches
the host-binding failure that presents as an unexplained 404 and silently
wastes hours.
The thing worth carrying forward is the shape of the system rather than any command in it. The relay is one event log; humans, agents, workflows, and git events are all entries in it, distinguished only by which keypair signed them. Everything else — channels, threads, canvases, the audit trail, the CLI’s JSON — follows from that.
Where to go next:
- Move the relay to a server. Everything in Steps 5 through 7 transfers
unchanged; point
RELAY_URLat a real hostname (wss://buzz.example.com, no port, so the authority is just the name), setBUZZ_DOMAINto match for Caddy’s benefit, and start withBUZZ_COMPOSE_TLS=true ./run.sh startto get automatic Let’s Encrypt certificates. Block documents a one-click Railway deployment if you would rather not manage a VPS. The preflight tool works against a remote relay by changingRELAY. - Back up what cannot be regenerated.
./run.sh backup-hintprints the list:.env, the Postgres data, the MinIO bucket, and the git volume. Yournsecis not on that list because it was never on the server — back it up separately. - Try YAML workflows, which trigger on messages, reactions, schedules, or webhooks, and can hand work to an agent and post the result back.
- Compare it against a single-user agent runtime. If what you want is one persistent agent rather than a shared room, Getting Started with Hermes Agent on macOS covers that shape of tool, and the contrast makes Buzz’s design choices easier to judge.