feat(buzz): WebSocket inbound transport — NIP-42 auth, live DM discovery, poll fallback

Consolidates the native-transport half of PR #73636 by @ScaleLeanChris
onto the merged adapter: persistent NIP-42-authenticated Nostr WebSocket
subscription as the default inbound path (transport=auto|websocket|poll),
kind-44100 membership events for live DM discovery, since-timestamp
resume on reconnect with bounded exponential backoff, and automatic
fallback to CLI polling when the WS can't be established. Events route
through the same _handle_event() pipeline as the poll loop, so de-dupe,
mention gating, p-tag DM latching, and allow-lists behave identically on
both transports. Outbound stays on the CLI (one-shot sends never race a
WS auth handshake — his design).

E2E verified against a real in-process websockets relay: NIP-42
challenge -> signed kind-22242 AUTH (event id re-derived server-side) ->
REQ subscription -> EVENT dispatch -> clean disconnect.

Co-authored-by: ScaleLeanChris <chris@scalelean.com>
This commit is contained in:
Teknium 2026-07-28 18:22:28 -07:00
parent 21c7b806a3
commit 07e931fcb4
5 changed files with 509 additions and 5 deletions

View file

@ -0,0 +1 @@
ScaleLeanChris

View file

@ -47,6 +47,7 @@ from collections import OrderedDict
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlsplit, urlunsplit
logger = logging.getLogger(__name__)
@ -75,11 +76,40 @@ _DEFAULT_POLL_INTERVAL = 4.0
_MIN_POLL_INTERVAL = 1.0
_CLI_TIMEOUT = 30.0
# WebSocket transport (NIP-42 authenticated Nostr subscription).
# kind 44100 is Buzz's channel-membership event — used for live DM discovery.
_WS_AUTH_TIMEOUT = 20.0
_WS_MAX_MESSAGE_BYTES = 2_000_000
_WS_MEMBERSHIP_KIND = 44100
_WS_MEMBERSHIP_SUB_ID = "hermes-buzz-membership"
# Where to look for a credentials JSON (keys: nsec / private_key_hex) when
# BUZZ_PRIVATE_KEY is not set. Module-level so tests can point it at a tmpdir.
_DEFAULT_CREDENTIALS_DIR = Path("~/.config/buzz").expanduser()
def _load_nostr_auth():
"""Import the sibling nostr_auth module in a loader-agnostic way.
The adapter is imported both as a package module
(``plugins.platforms.buzz.adapter``) and as a bare single-file module by
the test plugin loader, where relative imports have no parent package.
"""
try:
from . import nostr_auth # type: ignore[no-redef]
return nostr_auth
except ImportError:
import importlib.util
path = Path(__file__).with_name("nostr_auth.py")
spec = importlib.util.spec_from_file_location("plugin_adapter_buzz_nostr_auth", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# ---------------------------------------------------------------------------
# bech32 (BIP-173) helpers — used to convert between npub and hex pubkeys so
# mention detection and allow-lists accept either form. Pure stdlib.
@ -338,6 +368,15 @@ class BuzzAdapter(BasePlatformAdapter):
_rm_cfg = _rm_raw
self.require_mention = str(_rm_cfg).strip().lower() not in ("false", "0", "no", "off")
# Inbound transport: "auto" (WebSocket with poll fallback, default),
# "websocket" (require WS; fail connect when it can't authenticate),
# or "poll" (CLI polling only). Env (BUZZ_TRANSPORT) overrides
# config.yaml.
_transport = (
os.getenv("BUZZ_TRANSPORT") or str(extra.get("transport", "auto") or "auto")
).strip().lower()
self.transport = _transport if _transport in ("auto", "websocket", "poll") else "auto"
# Auth: entries may be hex pubkeys or npubs; normalized to hex
raw_allowed = os.getenv("BUZZ_ALLOWED_USERS") or extra.get("allowed_users", [])
if isinstance(raw_allowed, str):
@ -360,6 +399,10 @@ class BuzzAdapter(BasePlatformAdapter):
# Runtime state
self._poll_task: Optional[asyncio.Task] = None
self._ws_task: Optional[asyncio.Task] = None
self._ws_ready: Optional[asyncio.Event] = None
self._ws_active = False # True while the WS loop owns inbound delivery
self._membership_since = 0
self._lock_key: Optional[str] = None
# channel_id -> {"chat_type", "last_ts", "seen": OrderedDict[event_id, None]}
self._channel_state: Dict[str, dict] = {}
@ -471,19 +514,37 @@ class BuzzAdapter(BasePlatformAdapter):
await self._seed_channel(channel_id, chat_type="group")
await self._discover_dms(seed=True)
self._poll_task = asyncio.create_task(self._poll_loop())
# Inbound transport: prefer the NIP-42-authenticated WebSocket
# subscription (push, near-zero latency); fall back to CLI polling
# when the WS can't be established (transport="auto") or when the
# user pinned transport="poll".
transport_used = "poll"
if self.transport in ("auto", "websocket"):
if await self._start_websocket():
transport_used = "websocket"
elif self.transport == "websocket":
self._set_fatal_error(
"ws_auth_failed",
"Buzz WebSocket transport did not authenticate (transport=websocket)",
retryable=True,
)
await self.disconnect()
return False
if transport_used == "poll":
self._poll_task = asyncio.create_task(self._poll_loop())
self._mark_connected()
logger.info(
"Buzz: connected to %s as %s, watching %d channel(s), poll interval %.1fs",
"Buzz: connected to %s as %s, watching %d channel(s) via %s%s",
self.relay_url,
self._display_name or self._self_npub[:16],
len(self._channel_state),
self.poll_interval,
transport_used,
"" if transport_used == "websocket" else f", poll interval {self.poll_interval:.1f}s",
)
return True
async def disconnect(self) -> None:
"""Stop the poll loop and drop runtime state."""
"""Stop the inbound transport and drop runtime state."""
self._mark_disconnected()
lock_key = getattr(self, "_lock_key", None)
if lock_key:
@ -494,6 +555,14 @@ class BuzzAdapter(BasePlatformAdapter):
except Exception:
pass
self._lock_key = None
self._ws_active = False
if self._ws_task and not self._ws_task.done():
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
self._ws_task = None
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
try:
@ -626,6 +695,185 @@ class BuzzAdapter(BasePlatformAdapter):
pass
return {"name": name or chat_id, "type": chat_type, "chat_id": chat_id}
# ── Inbound: WebSocket transport (NIP-42 authenticated) ──────────────
#
# Push transport contributed in PR #73636 by @ScaleLeanChris, adapted to
# dispatch through the same _handle_event() machinery as the poll loop so
# de-dupe, mention gating, DM latching, and the allow-list behave
# identically on both transports.
def _websocket_url(self) -> str:
parsed = urlsplit(self.relay_url.strip())
scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme)
if scheme not in ("ws", "wss") or not parsed.netloc:
raise ValueError("Buzz relay URL must use http(s) or ws(s)")
return urlunsplit((scheme, parsed.netloc, parsed.path or "", parsed.query, ""))
async def _start_websocket(self) -> bool:
"""Start the WS loop; True when it authenticates within the timeout."""
try:
import websockets # noqa: F401 (availability probe)
self._websocket_url()
except Exception as e:
logger.info("Buzz: WebSocket transport unavailable (%s); falling back to polling", e)
return False
self._ws_ready = asyncio.Event()
self._membership_since = int(time.time())
self._ws_task = asyncio.create_task(self._websocket_loop())
try:
await asyncio.wait_for(self._ws_ready.wait(), timeout=_WS_AUTH_TIMEOUT + 5)
except (asyncio.TimeoutError, TimeoutError):
logger.warning("Buzz: WebSocket did not authenticate in time")
self._ws_active = False
if self._ws_task and not self._ws_task.done():
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
self._ws_task = None
return False
return True
async def _authenticate_websocket(self, websocket) -> None:
"""NIP-42: wait for the relay's AUTH challenge, answer with a signed
kind-22242 event (plus the optional NIP-OA owner-attestation tag from
BUZZ_AUTH_TAG), and wait for the OK acknowledgment."""
build_auth_event = _load_nostr_auth().build_auth_event
raw = await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT)
message = json.loads(raw)
if not isinstance(message, list) or len(message) < 2 or message[0] != "AUTH":
raise ConnectionError("Buzz relay did not send a NIP-42 AUTH challenge")
event = build_auth_event(
private_key=self._private_key,
challenge=str(message[1]),
relay_url=self._websocket_url(),
auth_tag_json=os.getenv("BUZZ_AUTH_TAG", ""),
)
await websocket.send(json.dumps(["AUTH", event], separators=(",", ":")))
while True:
raw = await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT)
response = json.loads(raw)
if not isinstance(response, list) or not response:
continue
if response[0] == "OK" and len(response) >= 4 and response[1] == event["id"]:
if response[2] is True:
return
raise ConnectionError(f"Buzz WebSocket AUTH rejected: {response[3]}")
if response[0] in ("NOTICE", "CLOSED"):
detail = response[-1] if len(response) > 1 else "authentication failed"
raise ConnectionError(f"Buzz WebSocket AUTH failed: {detail}")
async def _send_channel_subscription(self, websocket, subscription_id: str, channel_id: str) -> None:
state = self._channel_state.get(channel_id) or {}
since = max(int(state.get("last_ts") or time.time()) - 1, 0)
request = [
"REQ",
subscription_id,
{"kinds": [_CHAT_KIND], "#h": [channel_id], "since": since},
]
await websocket.send(json.dumps(request, separators=(",", ":")))
async def _subscribe_websocket(self, websocket) -> Dict[str, Optional[str]]:
"""Subscribe to every watched conversation plus membership events
(kind 44100 p-tagged to us) for live DM discovery."""
subscriptions: Dict[str, Optional[str]] = {}
for index, channel_id in enumerate(list(self._channel_state)):
subscription_id = f"hermes-buzz-{index}"
subscriptions[subscription_id] = channel_id
await self._send_channel_subscription(websocket, subscription_id, channel_id)
if self._self_pubkey:
request = [
"REQ",
_WS_MEMBERSHIP_SUB_ID,
{
"kinds": [_WS_MEMBERSHIP_KIND],
"#p": [self._self_pubkey],
"since": max(self._membership_since - 1, 0),
},
]
await websocket.send(json.dumps(request, separators=(",", ":")))
subscriptions[_WS_MEMBERSHIP_SUB_ID] = None
return subscriptions
async def _handle_membership_event(self, websocket, subscriptions: Dict[str, Optional[str]], event: dict) -> None:
"""A membership event p-tagged to us: rediscover conversations and
subscribe to any new ones (fresh DMs dispatch from their beginning)."""
self._membership_since = max(self._membership_since, int(event.get("created_at") or 0))
before = set(self._channel_state)
await self._discover_dms(seed=False)
for channel_id in self._channel_state:
if channel_id in before:
continue
subscription_id = f"hermes-buzz-dm-{len(subscriptions)}"
subscriptions[subscription_id] = channel_id
await self._send_channel_subscription(websocket, subscription_id, channel_id)
logger.info("Buzz: subscribed to new conversation %s", channel_id)
async def _websocket_loop(self) -> None:
"""Persistent authenticated subscription with bounded reconnect
backoff. Events route through _handle_event() identical semantics
to the poll loop. On reconnect, per-channel `since` filters resume
from the last observed timestamps (same-second overlap de-duped by
event id)."""
import websockets
backoff = 1.0
try:
while True:
try:
async with websockets.connect(
self._websocket_url(),
open_timeout=_WS_AUTH_TIMEOUT,
close_timeout=5,
ping_interval=20,
ping_timeout=20,
max_size=_WS_MAX_MESSAGE_BYTES,
) as websocket:
await self._authenticate_websocket(websocket)
subscriptions = await self._subscribe_websocket(websocket)
self._ws_active = True
if self._ws_ready is not None:
self._ws_ready.set()
backoff = 1.0
async for raw in websocket:
try:
message = json.loads(raw)
except (ValueError, TypeError):
logger.warning("Buzz: ignoring malformed WebSocket frame")
continue
if not isinstance(message, list) or not message:
continue
if message[0] == "EVENT" and len(message) >= 3:
subscription_id = str(message[1])
event = message[2]
if not isinstance(event, dict):
continue
if subscription_id == _WS_MEMBERSHIP_SUB_ID:
await self._handle_membership_event(websocket, subscriptions, event)
continue
channel_id = subscriptions.get(subscription_id)
state = self._channel_state.get(channel_id or "")
if channel_id and state is not None:
await self._handle_event(channel_id, state, event)
self._trim_seen(state)
elif message[0] == "CLOSED":
detail = message[-1] if len(message) > 2 else "subscription closed"
raise ConnectionError(str(detail))
elif message[0] == "NOTICE":
logger.warning("Buzz: relay notice: %s", message[-1])
except asyncio.CancelledError:
raise
except Exception as e:
self._ws_active = False
logger.warning("Buzz: WebSocket disconnected; retrying in %.1fs: %s", backoff, e)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30.0)
finally:
self._ws_active = False
# ── Inbound polling ───────────────────────────────────────────────────
async def _poll_loop(self) -> None:
@ -1021,6 +1269,7 @@ def _apply_yaml_config(yaml_cfg: dict, buzz_cfg: dict) -> Optional[dict]:
"relay_url": "BUZZ_RELAY_URL",
"cli_path": "BUZZ_CLI_PATH",
"home_channel": "BUZZ_HOME_CHANNEL",
"transport": "BUZZ_TRANSPORT",
}
for src, env in _str_keys.items():
val = extra.get(src)

View file

@ -21,6 +21,14 @@ requires_env:
prompt: "Nostr private key (nsec or hex)"
password: true
optional_env:
- name: BUZZ_TRANSPORT
description: "Inbound transport: auto (WebSocket w/ poll fallback, default), websocket, or poll"
prompt: "Transport (auto/websocket/poll)"
password: false
- name: BUZZ_AUTH_TAG
description: "Optional NIP-OA owner-attestation auth tag JSON for NIP-42 WebSocket auth"
prompt: "NIP-OA auth tag JSON (or empty)"
password: false
- name: BUZZ_CHANNELS
description: "Comma-separated channel UUIDs to watch (default: all joined channels)"
prompt: "Channel UUIDs (comma-separated)"

View file

@ -0,0 +1,244 @@
"""Tests for the Buzz WebSocket transport (NIP-42) and Nostr signing module.
The signing module and WS transport were contributed in PR #73636 by
@ScaleLeanChris and consolidated onto the merged poll-based adapter; these
tests cover the crypto (against the official BIP-340 vector) and the WS
lifecycle as wired into BuzzAdapter.
"""
import asyncio
import json
import time
import pytest
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
_buzz_mod = load_plugin_adapter("buzz")
BuzzAdapter = _buzz_mod.BuzzAdapter
import importlib.util as _ilu
from pathlib import Path as _Path
_auth_path = _Path(_buzz_mod.__file__).with_name("nostr_auth.py")
_spec = _ilu.spec_from_file_location("plugin_adapter_buzz_nostr_auth", _auth_path)
nostr_auth = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(nostr_auth)
SELF_PUBKEY = "9fd5c7ba6d3ef224da78f541e0fcb9c50f72cc63edb19aae76ac6a0474dfa860"
# BIP-340 test vector 0 private key
TEST_PRIVATE_KEY = "00" * 31 + "03"
CHANNEL = "ccc2bc1a-7a82-5a8f-8c4e-57a070cbe7cd"
def _make_adapter(extra=None):
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={"relay_url": "https://test.relay", **(extra or {})})
adapter = BuzzAdapter(cfg)
adapter._self_pubkey = SELF_PUBKEY
adapter._private_key = TEST_PRIVATE_KEY
adapter._display_name = "Chip"
return adapter
# ── nostr_auth: BIP-340 / NIP-42 ──────────────────────────────────────────
def test_schnorr_sign_matches_official_bip340_vector_zero():
signature = nostr_auth.schnorr_sign(
bytes(32), TEST_PRIVATE_KEY, auxiliary_randomness=bytes(32)
)
assert nostr_auth.public_key_hex(TEST_PRIVATE_KEY).upper() == (
"F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9"
)
assert signature.hex().upper() == (
"E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA8215"
"25F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0"
)
def test_decode_private_key_rejects_bad_input():
with pytest.raises(ValueError):
nostr_auth.decode_private_key("not-a-key")
with pytest.raises(ValueError):
nostr_auth.decode_private_key("00" * 32) # zero — outside range
with pytest.raises(ValueError):
nostr_auth.decode_private_key("nsec1qqqqqqqq") # bad checksum/length
def test_build_auth_event_shape_and_owner_tag():
tag = json.dumps(["auth", "b" * 64, "", "c" * 128])
event = nostr_auth.build_auth_event(
private_key=TEST_PRIVATE_KEY,
challenge="challenge-1",
relay_url="wss://relay.example",
auth_tag_json=tag,
created_at=1_700_000_000,
auxiliary_randomness=bytes(32),
)
assert event["kind"] == 22242
assert ["relay", "wss://relay.example"] in event["tags"]
assert ["challenge", "challenge-1"] in event["tags"]
assert ["auth", "b" * 64, "", "c" * 128] in event["tags"]
assert len(bytes.fromhex(event["sig"])) == 64
assert event["pubkey"] == nostr_auth.public_key_hex(TEST_PRIVATE_KEY)
def test_build_auth_event_rejects_malformed_auth_tag():
with pytest.raises(ValueError):
nostr_auth.build_auth_event(
private_key=TEST_PRIVATE_KEY,
challenge="c",
relay_url="wss://r",
auth_tag_json='{"not": "a list"}',
)
# ── Adapter WS wiring ─────────────────────────────────────────────────────
class _FakeWebSocket:
"""Replays a NIP-42 handshake: AUTH challenge, then OK for the reply."""
def __init__(self):
self.sent = []
async def recv(self):
if self.sent:
auth_event = self.sent[0][1]
return json.dumps(["OK", auth_event["id"], True, "authenticated"])
return json.dumps(["AUTH", "relay-challenge"])
async def send(self, raw):
self.sent.append(json.loads(raw))
def test_transport_config_parsing():
assert _make_adapter().transport == "auto"
assert _make_adapter({"transport": "poll"}).transport == "poll"
assert _make_adapter({"transport": "websocket"}).transport == "websocket"
assert _make_adapter({"transport": "bogus"}).transport == "auto"
def test_websocket_url_converts_rest_schemes():
adapter = _make_adapter()
adapter.relay_url = "https://community.example/path"
assert adapter._websocket_url() == "wss://community.example/path"
adapter.relay_url = "http://localhost:3000"
assert adapter._websocket_url() == "ws://localhost:3000"
adapter.relay_url = "ftp://nope"
with pytest.raises(ValueError):
adapter._websocket_url()
@pytest.mark.asyncio
async def test_websocket_auth_signs_nip42_challenge(monkeypatch):
adapter = _make_adapter()
monkeypatch.setenv("BUZZ_AUTH_TAG", json.dumps(["auth", "b" * 64, "", "c" * 128]))
ws = _FakeWebSocket()
await adapter._authenticate_websocket(ws)
frame = ws.sent[0]
assert frame[0] == "AUTH"
event = frame[1]
assert event["kind"] == 22242
assert ["challenge", "relay-challenge"] in event["tags"]
assert ["auth", "b" * 64, "", "c" * 128] in event["tags"]
@pytest.mark.asyncio
async def test_websocket_auth_raises_on_rejection():
adapter = _make_adapter()
class RejectingWs(_FakeWebSocket):
async def recv(self):
if self.sent:
auth_event = self.sent[0][1]
return json.dumps(["OK", auth_event["id"], False, "denied"])
return json.dumps(["AUTH", "relay-challenge"])
with pytest.raises(ConnectionError):
await adapter._authenticate_websocket(RejectingWs())
@pytest.mark.asyncio
async def test_subscriptions_resume_from_channel_high_water_marks():
adapter = _make_adapter()
adapter._channel_state = {
"general-id": {"chat_type": "group", "last_ts": 100, "seen": {}},
"dm-one": {"chat_type": "dm", "last_ts": 200, "seen": {}},
}
adapter._membership_since = 300
ws = _FakeWebSocket()
subscriptions = await adapter._subscribe_websocket(ws)
assert subscriptions["hermes-buzz-0"] == "general-id"
assert subscriptions["hermes-buzz-1"] == "dm-one"
assert subscriptions[_buzz_mod._WS_MEMBERSHIP_SUB_ID] is None
assert ws.sent[0][2]["#h"] == ["general-id"]
assert ws.sent[0][2]["since"] == 99
assert ws.sent[1][2]["since"] == 199
assert ws.sent[2][2]["kinds"] == [_buzz_mod._WS_MEMBERSHIP_KIND]
assert ws.sent[2][2]["since"] == 299
@pytest.mark.asyncio
async def test_ws_events_route_through_handle_event_with_dedup():
"""WS events use the same _handle_event pipeline as the poll loop —
same de-dupe, same mention gate, same DM semantics."""
adapter = _make_adapter()
adapter._channel_state = {
CHANNEL: {"chat_type": "dm", "last_ts": 0, "seen": __import__("collections").OrderedDict()}
}
received = []
async def handle_message(event):
received.append(event)
adapter.handle_message = handle_message
adapter._message_handler = handle_message # dispatch gate requires a handler
adapter._user_names["b" * 64] = "user"
event = {
"id": "evt-1",
"kind": 9,
"pubkey": "b" * 64,
"content": "hello",
"created_at": 1_700_000_000,
"tags": [],
}
state = adapter._channel_state[CHANNEL]
await adapter._handle_event(CHANNEL, state, event)
await adapter._handle_event(CHANNEL, state, event)
assert len(received) == 1
assert state["last_ts"] == 1_700_000_000
@pytest.mark.asyncio
async def test_start_websocket_times_out_and_cleans_up(monkeypatch):
adapter = _make_adapter()
monkeypatch.setattr(_buzz_mod, "_WS_AUTH_TIMEOUT", 0.0)
async def never_ready():
await asyncio.sleep(3600)
monkeypatch.setattr(adapter, "_websocket_loop", never_ready)
assert await adapter._start_websocket() is False
assert adapter._ws_task is None
assert adapter._ws_active is False
@pytest.mark.asyncio
async def test_membership_event_subscribes_new_conversations():
adapter = _make_adapter()
adapter._channel_state = {"old-chan": {"chat_type": "group", "last_ts": 5, "seen": {}}}
async def fake_discover(seed):
adapter._channel_state["new-dm"] = {"chat_type": "dm", "last_ts": 0, "seen": {}}
adapter._discover_dms = fake_discover
ws = _FakeWebSocket()
subscriptions = {"hermes-buzz-0": "old-chan"}
await adapter._handle_membership_event(
ws, subscriptions, {"created_at": int(time.time()), "tags": []}
)
assert "new-dm" in subscriptions.values()
assert any(f[2].get("#h") == ["new-dm"] for f in ws.sent)

View file

@ -1,9 +1,11 @@
# Buzz
The Buzz adapter connects Hermes to a [Buzz](https://github.com/block/buzz) community — Block's open-source human+agent collaboration platform built on the Nostr protocol — and relays messages between Buzz channels (or DMs) and the agent. The adapter is pure Python stdlib: it shells out to the `buzz` CLI binary ("JSON in, JSON out") instead of speaking Nostr itself, so **no Python packages are required** — just the `buzz` binary.
The Buzz adapter connects Hermes to a [Buzz](https://github.com/block/buzz) community — Block's open-source human+agent collaboration platform built on the Nostr protocol — and relays messages between Buzz channels (or DMs) and the agent. Outbound traffic shells out to the `buzz` CLI binary ("JSON in, JSON out"); inbound uses a native Nostr WebSocket subscription (via the already-bundled `websockets` package) with CLI polling as fallback. **No extra Python packages are required** — just the `buzz` binary.
Buzz renders markdown, so agent replies keep their formatting. Images are delivered as uploads (local files) or links (URLs). Replies can thread onto an existing message via its event id.
Inbound messages arrive over a persistent NIP-42-authenticated Nostr WebSocket subscription by default (near-instant delivery), with automatic fallback to CLI polling when the WebSocket can't be established. Outbound messages always go through the `buzz` CLI. Control it with `transport` / `BUZZ_TRANSPORT`: `auto` (default), `websocket` (require WS, fail otherwise), or `poll`. If your relay membership uses NIP-OA owner attestation, set `BUZZ_AUTH_TAG` to the four-string auth tag JSON.
> Run `hermes gateway setup` and pick **Buzz** for a guided walk-through.
## Prerequisites