> Blog Post

Run Your Own Buzz: A Local Community on Hardware You Control

Buzz is Block's open-source (Apache 2.0) workspace for teams where some of the teammates are processes. The tagline on the repo is "a workspace where humans and agents build together, on a relay you own," and the second half of that is the interesting part: the whole thing is self-hostable, and the relay is the product.

It is worth being precise about what it is, because "AI-adjacent team chat" undersells the architecture. Buzz is a Nostr relay. Every message, reaction, workflow step, review approval, and git event is a signed event in one append-only log. Same shape, same identity model, same audit trail, whether the author is a person typing or an agent holding a keypair. Agents in Buzz are not a chat sidebar — they open repos, send patches, review code, run workflows, and join voice huddles through the same event surface a human uses, differing only by which key signed the event.

This post is the hands-on part: getting a community running on your own machine, with a real client connected to it. I did exactly this on a Linux box with the desktop client on a second laptop, and the walkthrough below is what actually worked, including the one piece of the design that will stop you cold if you do not know about it in advance.

The shape of a deployment

  desktop (Tauri 2 + React)   mobile (Flutter)   buzz CLI   ACP agent harness
             │                       │              │              │
             └───────────────┬───────┴──────────────┴──────────────┘
                             │  NIP-29 over WebSocket (NIP-42 auth)
                             ▼
                  ┌──────────────────────────┐
                  │        buzz-relay        │  ← also hosts git smart HTTP
                  │  host → community bind   │    and huddle audio
                  └────┬──────────┬──────────┘
                       │          │
        ┌──────────────▼──┐   ┌───▼──────────────┐
        │ Postgres        │   │ Redis            │
        │ events, FTS,    │   │ pub/sub fan-out, │
        │ audit hash-chain│   │ presence, typing │
        └─────────────────┘   └──────────────────┘

The Rust workspace splits along those seams — buzz-relay (the server), buzz-core (types, event verification, the kind registry), buzz-db, buzz-auth, buzz-pubsub, buzz-search, buzz-audit, buzz-media — plus a set of crates for the agent surface (buzz-acp, buzz-agent, buzz-workflow, buzz-dev-mcp) and clients (desktop, mobile, web, CLI).

For a local community you need the relay, Postgres, and Redis. Everything else is optional.

The one decision to make first: the URL is the community

A Buzz community is the workspace a user reaches by URL, and that is enforced far more literally than you might expect.

The relay resolves the community from the connection's HTTP Host header, looked up against a communities table. This happens at connection establishment, before any handler sees tenant data — the source calls it "row zero." A host that maps to no community fails closed. There is no default tenant, no fallback:

The host is the authoritative selector; an unknown or unmapped host fails closed with a generic rejection and never falls through to a default tenant. — crates/buzz-relay/src/tenant.rs

At startup the relay seeds exactly one community, deriving its host from the RELAY_URL environment variable. The dev bootstrap script does the same thing, with one accommodation: if RELAY_URL points at localhost or 127.0.0.1, it also seeds the loopback aliases (with and without port), because local tooling has historically used both. Non-loopback deployments seed only RELAY_URL's authority.

The practical consequence, which is the whole reason this section exists:

  • Leave RELAY_URL=ws://localhost:3000 and you get a working single-machine setup that nothing else on your network can reach, because Host: 10.0.0.136:3000 is unmapped and gets rejected.
  • Set RELAY_URL to your LAN address and the LAN works, but localhost stops working on the relay's own machine — same fail-closed rule, opposite direction.

Decide which one you want before first boot. It is a one-line change and a restart to fix, but it is a confusing five minutes if you hit it blind, because the failure looks like a network problem and is actually an authorization one.

Bringing it up

Prerequisites are Docker and the repo's Hermit toolchain, which pins Rust, Node, and the rest — you do not install a toolchain by hand.

git clone https://github.com/block/buzz && cd buzz
. ./bin/activate-hermit
cp .env.example .env

Then edit .env if the client lives on another machine. The bind address already defaults to all interfaces, so this is the only change:

BUZZ_BIND_ADDR=0.0.0.0:3000        # already the default
RELAY_URL=ws://10.0.0.136:3000     # your LAN address, not localhost

Now start it:

just relay

That one recipe auto-starts the Docker services, runs migrations, seeds the community host row, and then builds and runs the relay. (just setup does the same infrastructure work and additionally installs the desktop app's dependencies — use it if you also plan to build the client from source.) The first build is a from-scratch Rust compile of a large workspace, so expect to wait.

You are up when the log says so. It is structured JSON:

{"message":"Deployment community ensured","host":"10.0.0.136:3000","community":"61c179c1-…"}
{"message":"buzz-relay TCP listening","addr":"0.0.0.0:3000"}

Verify before you trust it

Two checks worth running, because they distinguish "the port is open" from "the relay will actually talk to you." First, NIP-11 relay metadata over plain HTTP:

curl -s -H "Accept: application/nostr+json" http://10.0.0.136:3000/
{"name":"Buzz Relay","supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56]}

Then a real WebSocket upgrade, which is what a client does:

curl -s -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Key: $(head -c16 /dev/urandom | base64)" \
  -H "Sec-WebSocket-Version: 13" http://10.0.0.136:3000/

HTTP/1.1 101 Switching Protocols means you are done. If you instead get 404 Not Found, you have hit the host binding — the Host you sent is not the one in communities. That is the failure mode from the previous section, and it is worth triggering deliberately once so you recognize it later.

Connecting a client

In the desktop app's onboarding, three cards look equivalent and are not. "Create a community" is the hosted path — it opens a browser to sign in to a managed service, provisions a relay for you, and has nothing to do with the one you just started. For your own relay you want either:

  • Join a community, or
  • I already have a community → I'm a member or admin

Both land on the same form, which tries to parse your input as an invite code and falls back to treating it as a relay URL. Paste:

ws://10.0.0.136:3000

An explicit ws:// is preserved rather than upgraded to wss:// — only scheme-less input is assumed secure — so a plaintext LAN relay connects without a certificate. That is fine for a machine on your own network and not fine for anything past your router.

Making yourself the owner

Out of the box, require_relay_membership defaults to false, so a fresh local relay is open: any authenticated caller can connect and use it. You do not strictly need to do anything here. But if you want the owner role — for administering the community rather than just living in it — set one variable.

It wants a 64-character hex pubkey, while the client shows you an npub, so there is a bech32 decode in the middle:

npub17lte6rh3l5l5xme6zs6jz2ehwu04knxgcq8aecv7dl8gjtgrye3qgklq2m
  → f7d79d0ef1fd3f436f3a1435212b37771f5b4cc8c00fdce19e6fce892d032662

Add it and restart:

echo 'RELAY_OWNER_PUBKEY=f7d79d0e…' >> .env
just relay

Usefully, this is not a one-shot bootstrap you can miss. ensure_configured_community is idempotent and bootstrap_owner re-runs on every startup, explicitly to "ensure the configured relay owner always holds the owner role." So you can bring the relay up first, grab your npub from the connected client, and add it afterward. The log confirms it, and so does the relay_members table:

{"message":"Relay owner bootstrapped","pubkey":"f7d79d0e…"}

If the client is on another machine

Two things beyond the relay itself:

Your firewall. The relay binds 0.0.0.0 but your host firewall probably does not care. Scope the rule to your subnet rather than opening the port to everything:

sudo ufw allow from 10.0.0.0/24 to any port 3000 proto tcp

Worth knowing that you cannot sanity-check this from the relay's own machine — traffic to a local IP never traverses the INPUT chain, so a curl from there succeeds whether or not the rule exists. Test from the other device or you are testing nothing.

Your DHCP lease. The community host row contains a literal address. If the lease moves, every client starts getting 404s that look nothing like a DNS problem. A static reservation or an mDNS name avoids an annoying afternoon.

What you have now

An empty community on your own hardware: no channels yet, one owner, and an event log with nothing in it. Create channels in the app or through the buzz CLI, which is the agent-first surface — every read returns signature-stripped JSON and every write returns {event_id, accepted, message}, which makes it pleasant to drive from a script or an agent.

That is a working workspace, and if you only wanted self-hosted team chat you can stop here. But an empty relay is the least interesting version of Buzz. The next section is the reason to have built it.

Putting an agent in the room

This is the part that makes the self-hosting worth it. An agent in Buzz is not a chatbot integration — it is a member with a keypair, and it acts through the same signed-event log everyone else uses.

Three processes, two hops:

  relay ──WS──► buzz-acp ──stdio (ACP/JSON-RPC)──► agent ──HTTPS──► model
   ▲             harness:                          │
   │             watches @mentions,                 │ stdio (MCP)
   │             spawns the agent,                  ▼
   └── buzz CLI ─── injects credentials ────── MCP tool servers

The harness (buzz-acp) holds the relay connection, watches for @mentions in channels the agent belongs to, and prompts the agent over ACP — JSON-RPC over stdio. The agent calls a model, gets back tool calls, runs them through MCP servers, and replies by shelling out to the buzz CLI, which the harness has already configured with credentials.

So there are two independent choices: which agent binary the harness spawns, and which model that binary talks to. Mixing them is the whole point — the harness works with goose, codex, and claude-code out of the box, or with the in-repo buzz-agent, which is the easiest one to point at an arbitrary model.

Mint the agent an identity

The agent needs its own keypair. It is a distinct member of your community, not a delegation of you:

cargo run -p buzz-admin -- generate-key

Save the secret key when it prints — it is not stored and cannot be recovered. Mint a separate keypair per agent if you run more than one.

On the relay we just built you can stop here: require_relay_membership defaults to false, so the agent can authenticate and publish immediately. If you tighten that later, you also need to register the pubkey, which publishes a kind:13534 membership event and therefore needs the relay to have a stable signing key of its own (BUZZ_RELAY_PRIVATE_KEY in .env, then a restart):

BUZZ_RELAY_PRIVATE_KEY=<relay signing key> \
  cargo run -p buzz-admin -- add-member --pubkey <agent public key>

The minimal agent

buzz-agent describes itself as "stdio in, tool calls out. Non-streaming. No persistence. No cleverness," and that plainness is the feature. Its loop is: call the model → get tool calls → run them via MCP → feed results back → repeat until the model stops asking or a cap trips.

Worth internalizing before you pick a model: the agent's output is its tool calls. Generated prose is forwarded to the client, but the work happens in tools. A model that cannot reliably call tools will connect, authenticate, chat, and accomplish nothing.

Configuration is entirely environment variables — it is a subprocess, and subprocess config is environment. BUZZ_AGENT_PROVIDER selects the HTTP dialect: anthropic, openai, databricks, or databricks_v2. The openai value is the interesting one, because it means "OpenAI-compatible," which covers most of the ecosystem.

cargo build --release -p buzz-agent

Option A: OpenRouter

OpenRouter is the shortest path to trying several frontier models against your community without holding accounts with each vendor. It is an OpenAI-compatible endpoint, so it goes through provider=openai with the base URL redirected:

export BUZZ_AGENT_PROVIDER=openai
export OPENAI_COMPAT_BASE_URL=https://openrouter.ai/api/v1
export OPENAI_COMPAT_API_KEY=sk-or-v1-...
export OPENAI_COMPAT_MODEL=anthropic/claude-sonnet-4.5   # any slug OpenRouter routes

You do not need to specify the wire format. OPENAI_COMPAT_API defaults to auto, which picks OpenAI's newer Responses API only for *.openai.com hosts and Chat Completions everywhere else — which is what OpenRouter wants. Pin it with OPENAI_COMPAT_API=chat if you ever need to override the guess.

Pick a model that OpenRouter marks as supporting tool calling. That constraint is doing more work than model quality here.

Option B: a local model — Hermes via Ollama or llama.cpp

Nothing has to leave your machine. The same provider=openai path points at a local server, since Ollama, llama.cpp, and vLLM all expose OpenAI-compatible endpoints:

export BUZZ_AGENT_PROVIDER=openai
export OPENAI_COMPAT_BASE_URL=http://localhost:11434/v1   # Ollama's default
export OPENAI_COMPAT_MODEL=hermes3
export OPENAI_COMPAT_API_KEY=ollama                       # ignored, but required

Two things to flag honestly.

First, that dummy API key is not optional. Selecting provider=openai without OPENAI_COMPAT_API_KEY is a startup error — the agent deliberately has no implicit fallback to another provider. Local servers ignore the value, but the config layer still demands one, and the failure is confusing if you do not expect it.

Second, there is no Hermes-specific integration in Buzz, and you should not go looking for one. Hermes is simply a local model that is trained for tool use and served over an OpenAI-compatible API, which is all the agent requires. The same three variables run any GGUF through llama.cpp's server, or a vLLM deployment on your own GPU:

export OPENAI_COMPAT_BASE_URL=http://localhost:8080/v1    # llama.cpp --server

The real selection criterion is tool-calling fidelity under a multi-round loop, not benchmark scores. The repo reports testing Ollama with llama3.1 and qwen2.5-coder, which is a reasonable signal for what class of local model holds up. Expect to spend your tuning effort on whether the model's template actually emits well-formed tool calls, not on prompt wording.

Option C: Claude Code, Codex, or goose — a different shape entirely

The options above configure a model. This one configures a coding agent that already has its own model, its own auth, and its own tools. The harness supports goose natively, and Claude Code and Codex through ACP adapters.

It is worth knowing how this differs, because it trades env-var configuration for a two-binary install and a very confusing failure mode.

There are no model or API-key variables to set. The claude runtime is provider-locked: authentication is whatever the Claude Code CLI itself holds, and Buzz just checks that it is present. What you need instead is two binaries, and installing the desktop app or the CLI gives you only one of them:

# 1. the CLI — carries the auth
curl -fsSL https://claude.ai/install.sh | bash

# 2. the ACP adapter — a separate npm package
npm install -g @agentclientprotocol/claude-agent-acp

command -v claude claude-agent-acp    # both must resolve

Buzz's readiness check for this runtime is a single exit-code test — it runs claude auth status and looks at nothing but the status code. The JSON body is ignored, so "loggedIn": true on stdout is not what satisfies it. Codex works the same way via codex login status and @agentclientprotocol/codex-acp.

Then the part that will cost you an hour if nobody tells you: readiness is evaluated once, when the agent launches — not per message. If anything is missing at that moment, the desktop app serializes the failure into an environment variable and starts the harness in a minimal "setup listener" mode instead of the agent pool. That process then replies to every mention with the same configuration nudge for its entire lifetime, and it never re-probes.

So the intuitive fix does not work. Installing the adapter and re-mentioning the agent changes nothing, because the running process already decided. You have to restart the agent — and if the tooling was installed after the app started, restart the desktop app too, since resolved-command lookups are cached process-wide including misses.

Compounding it, the nudge is not a status indicator. It is published as an ordinary signed event with a fenced buzz:config-nudge block the client renders as a card — which means it is a permanent entry in the channel log, and a nudge from twenty minutes ago is visually identical to a fresh one. After fixing anything here, judge the result from a new mention, not from the card already on screen.

The messages do at least distinguish the failures, which is worth reading carefully rather than skimming: claude ACP adapter isn't installed, claude CLI is missing, and complete Claude Code authentication by running the Claude CLI are three different problems. The last one means both binaries were found and the auth probe itself failed.

If any of this feels like more ceremony than you want, buzz-agent with OpenRouter is two environment variables and no adapter. The CLI-login runtimes are worth it when you specifically want that agent's tooling and skills in your community, not merely a model behind a keypair.

Wiring the agent to the harness

Point the harness at the binary and give it the relay and the agent's key:

cargo build --release -p buzz-acp

export BUZZ_ACP_AGENT_COMMAND=./target/release/buzz-agent
export BUZZ_PRIVATE_KEY=<agent secret key>      # hex or nsec both accepted
export BUZZ_RELAY_URL=ws://10.0.0.136:3000      # your community's URL

./target/release/buzz-acp

Swap BUZZ_ACP_AGENT_COMMAND for claude-agent-acp, codex-acp, or goose to run one of those instead — the rest of the invocation is unchanged, since the relay identity belongs to the harness rather than to the agent.

You do not need to set BUZZ_ACP_AGENT_ARGS in any of these cases. It defaults to acp for goose's benefit, but the harness matches on the command's basename and knows which runtimes take no subcommand — buzz-agent, claude-agent-acp, and codex-acp among them — and it even discards a stale acp for zero-argument agents. If you have goose installed, just goose is a one-liner wrapper for that path.

Note that BUZZ_RELAY_URL is subject to the same host binding as everything else. The agent connects over the network like any client, so it must use the community's real URL — pointing it at localhost on a LAN-configured relay produces the same fail-closed rejection a desktop client would get.

What to expect once it is running

The harness connects, authenticates with the agent's key, discovers the channels the agent is a member of, and waits. @mention the agent in one of those channels and you will see the round trip: prompt in, tool calls out, a reply published as a signed event by a different keypair than yours.

One current rough edge worth knowing: private channels require explicit membership, and the relay does not yet expose an API for managing channel members. The documented workaround is to have the agent create the channel via the CLI, since the creator is automatically a member.

From there the knobs that matter most are --agents (run up to 32 subprocesses in a pool) and --heartbeat-interval, which prompts the agent on a timer so it can do unprompted work rather than only reacting to mentions. That is the point at which a self-hosted Buzz stops looking like a chat app you own and starts looking like a place where work happens on its own.

The part worth sitting with

The self-hosted path is not a degraded mode. The relay you just built is the same binary the hosted product runs; the community boundary, the auth pipeline, the audit hash-chain, and the agent surface are all identical. What changes is only who holds the keys and who pays for the Postgres.

And with a local model behind the agent, the full loop — the workspace, the event log, the identities, and the inference — runs on hardware you can put your hand on. That is a genuinely unusual property for an agent platform, and it is the strongest argument for spending an afternoon on the setup above.