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 aarch64 build; “Processor: Intel …” means the x64 build.
  • 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.
  • uv 0.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, which rustup fetches for you; the rust-version = "1.88.0" in Cargo.toml is 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 256 gives 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 -readonly mounts the image at /Volumes/Buzz without opening a Finder window. -readonly is not paranoia — it prevents the mount from writing back to the image.
  • codesign -dv confirms the bundle carries a Developer ID signature from Block, Inc. (team EYF346PHUG). Notarization Ticket=stapled means the approval travels inside the bundle, so first launch works with no network round trip and no right-click → Open dance.
  • xcrun stapler validate checks only the ticket, and says so in one line. Prefer it when that is the question you are asking; codesign output is long and its field order is not stable enough to read positionally.
  • spctl -a -vvv asks Gatekeeper directly. accepted with source=Notarized Developer ID is the answer you want. If you ever see rejected, do not reach for xattr -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.app is 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:

PrefixWhat it isWhere it goes
nsec1…Secret key. Is your account.Password manager, nowhere else
npub1…Public key, bech32. Your address.Shared freely with people
64 hexThe 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

  • .env and *.env cover both this project and the relay’s deploy/compose/.env, which holds the Postgres password, the Redis password, the S3 credentials, and the relay’s own signing key.
  • owner-key.txt is a named trap. If you ever spill your nsec to a file while debugging, this stops the reflexive git add . from publishing it.
  • .venv/ keeps uv’s virtualenv out of the repo; it is rebuilt from uv.lock on 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

  • coincurve is a libsecp256k1 binding. It is needed for sign_schnorr, because NIP-42 authentication requires a BIP-340 Schnorr signature — not the ECDSA signature most crypto libraries hand you by default.
  • httpx covers the health endpoints and the NIP-11 document.
  • websockets is what makes the community-binding check possible; the failure only manifests during a real WebSocket upgrade.
  • requires-python = ">=3.11" is what allows X | None annotations and tomllib without 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_decode verifies the checksum, so a key mistyped by one character is rejected rather than silently decoding to the wrong 32 bytes.
  • _convertbits with pad=False is 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_key is the function this whole file was worth writing for. It accepts either form and returns hex. The expect_hrp keyword is a safety interlock: asking for an npub and being handed an nsec raises rather than quietly writing your secret key into a config file that is about to be read by a container.
  • event_id implements the NIP-01 canonical serialization: a six-element JSON array with no whitespace and no ASCII escaping, hashed with SHA-256. separators=(",", ":") and ensure_ascii=False are both load-bearing — either default would produce a different hash and every signature would be rejected.
  • websocket_url deliberately does not normalize the hostname. Helpfully resolving localhost to 127.0.0.1 would 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_env reports 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_http treats liveness, readiness, and NIP-11 as independent, so one failure does not mask the others.
  • The membership enforced check looks for NIP-43, and that choice matters. The obvious thing to test is the NIP-11 limitation object’s auth_required and restricted_writes flags — and it would be worthless, because the relay hardcodes both to true regardless 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 stable BUZZ_RELAY_PRIVATE_KEY is set, because the roster events are verified against the relay’s own key. You can confirm the difference yourself by setting BUZZ_REQUIRE_RELAY_MEMBERSHIP=false and restarting — 43 vanishes from supported_nips while auth_required stays true.
  • _ws_probe is the heart of it. Receiving ["AUTH", <challenge>] as the first frame proves the Host header bound to a community; that frame never arrives if it did not. When a secret key is supplied it goes on to build a kind 22242 event, Schnorr-sign it, and send it back — then issues a REQ and waits for EOSE to prove the identity is on the membership roster, not merely syntactically valid.
  • sign_schnorr, not sign. Nostr is BIP-340 throughout. An ECDSA signature here produces a valid-looking event that every relay rejects.
  • The 404 special 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_HEX come 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 full nsec1… teaches that printing secret keys is routine. The secret-key paths use throwaway_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_secp256k1 is what keeps the suite non-circular on the secret side: it encodes a generated key as an nsec, decodes it back, and checks the recovered secret derives the same npub. 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_required pins the interlock that prevents a secret key from being written into RELAY_OWNER_PUBKEY. It is the single most damaging paste available in this workflow.
  • test_reports_no_placeholders_once_filled_in guards against the check being vacuously true — a find_placeholders that always returned [] would pass the negative test but fail this one.
  • TestEventId does 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_verbatim exists purely to stop a future refactor from “fixing” websocket_url into uselessness.
  • sys.path.insert keeps 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 := help makes bare make print the help screen. The help target greps the Makefile for ## comments, so a new target documents itself by being written.
  • RELAY ?= and ENV_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 in argv where any user on the machine can read it out of ps. Keeping the secret in the environment and letting preflight.py read it with os.environ keeps it out of both shell history and the process table.
  • $(dir $(ENV_FILE)) derives the compose directory from the .env path, so the relay targets follow ENV_FILE automatically.
  • .PHONY covers 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_PUBKEY is your identity, and the relay reads it at first boot to seed the membership roster with you as owner. It is deliberately not prefixed BUZZ_; deploy/compose/README.md calls this out, and it is easy to “correct” into a variable nothing reads.
  • BUZZ_RELAY_PRIVATE_KEY is 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_URL is the one that provisions the community. The relay derives the community’s host from its authority — hostname plus port, with only :80 and :443 stripped as defaults — and matches the Host header of every later connection against it. With RELAY_URL=ws://localhost:3000 the community is bound to localhost:3000, so 127.0.0.1:3000 and even a bare localhost are both refused. This is the source of the 404 described in Step 4.
  • BUZZ_DOMAIN is 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_DOMAIN is read by nothing in the repository at all.
  • BUZZ_REQUIRE_RELAY_MEMBERSHIP=true is the one that closes the relay: only pubkeys on the roster are admitted. BUZZ_REQUIRE_AUTH_TOKEN=true governs the HTTP/NIP-98 bridge rather than reads — with it false, a dev-mode X-Pubkey header fallback activates. WebSocket read authentication is unconditional either way. Both are already set in the template and should stay.
  • BUZZ_AUTO_MIGRATE=true lets the relay run its own schema migrations on first boot. Without it you would run buzz-admin migrate by hand against an empty database.
  • chmod 600 because 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 config renders 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 start runs docker compose up -d --wait. The --wait is why the output reports Healthy rather than merely Started — it blocks until every health check passes, so a successful return means the stack is actually serving.

  • minio-init exits deliberately. It is a one-shot container that creates the media bucket and sets it non-public, then completes. An Exited status for that service is correct, not a failure.

  • list-members showing your pubkey with role owner and added_by - is the confirmation that RELAY_OWNER_PUBKEY was 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’s Host header has to match.

  • Useful neighbours: ./run.sh logs follows the relay, ./run.sh status shows service state, and ./run.sh stop shuts 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 URL check catches it before any connection is attempted, by comparing the authority you are dialing with the one RELAY_URL declares. The community binding check 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 curl alone. 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: 404
    
  • The 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 :80 and :443 are stripped, a relay on :3000 refuses a bare Host: localhost exactly as it refuses 127.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/
    done
    
    localhost:3000   -> 101
    localhost        -> 404
    127.0.0.1:3000   -> 404
    
  • The 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 in RELAY_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:

RuntimeStatus on a machine with Claude Code and Codex installed
Buzz AgentReady
OpenCodeReady
GooseCLI needed
Claude CodeAdapter needed
CodexAdapter 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_url rewrites ws:// to http:// and wss:// to https:// on the way in, precisely so a URL copied from an MCP config works unchanged.

  • BUZZ_PRIVATE_KEY accepts an nsec directly, so no conversion is needed. Read it from a password manager; anything you type after export lands in your shell history.

  • Errors are JSON on stderr as {"error": "<category>", "message": "<detail>"}, with documented exit codes: 0 ok, 1 bad input, 2 relay/network error, 3 auth error, 4 other, 5 write 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 --version flag. buzz --version returns a JSON user_error, which is startling the first time. Use buzz --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), and mem for 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_URL at a real hostname (wss://buzz.example.com, no port, so the authority is just the name), set BUZZ_DOMAIN to match for Caddy’s benefit, and start with BUZZ_COMPOSE_TLS=true ./run.sh start to 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 changing RELAY.
  • Back up what cannot be regenerated. ./run.sh backup-hint prints the list: .env, the Postgres data, the MinIO bucket, and the git volume. Your nsec is 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.