Skip to content

Buzz (Nostr) Channel

RoomKit talks to Block's Buzz — a Nostr-based team workspace — through a Nostr identity (a keypair) that holds a persistent, authenticated connection to the community's relay. Like Discord, it is wired as a source + provider pair sharing one connection, via the buzzkit client:

  • BuzzRelaySource owns the buzzkit.BuzzClient, authenticates (NIP-42), subscribes to a channel, and emits messages into RoomKit.
  • BuzzProvider reuses that client to publish replies over the relay's HTTP bridge.

A RoomKit Room maps to a Buzz channel; the agent's Nostr keypair is a first-class, cryptographically-signed member of the community.

Install

pip install roomkit[buzz]

buzzkit provides the Nostr bindings (Schnorr signing, event building) and the async relay client.

Join the community

Hosted Buzz communities are closed relays: the agent's key must be a member before it can read or write. The membership-gate-exempt path is an invite:

  1. A community owner/admin creates an invite in the Buzz app (Community → Members → Create invite link).
  2. Claim it once for your agent key with buzzkit:
from buzzkit import BuzzClient, generate_keypair

nsec, npub, _ = generate_keypair()   # your agent identity — store nsec securely
client = BuzzClient("wss://your-community.communities.buzz.xyz", nsec)
await client.claim_invite("https://your-community.communities.buzz.xyz/invite/<code>")

Then copy the target channel's UUID from the Buzz app.

Wire it up

from roomkit import BuzzChannel, RoomKit
from roomkit.providers.buzz import BuzzConfig, BuzzProvider
from roomkit.sources.buzz import BuzzRelaySource

config = BuzzConfig(
    relay_url="wss://your-community.communities.buzz.xyz",
    private_key="nsec1...",                     # the agent's Nostr secret
)
source = BuzzRelaySource(config, "buzz-main", relay_channel_id="<channel-uuid>")
provider = BuzzProvider(source)                 # reuses the source's client

kit = RoomKit()
kit.register_channel(BuzzChannel("buzz-main", provider=provider))
await kit.create_room(room_id="buzz-room")
await kit.attach_channel(
    "buzz-room", "buzz-main", metadata={"buzz_channel_id": "<channel-uuid>"}
)
await kit.attach_source("buzz-main", source)    # connects + subscribes

Register one channel per Buzz channel you want to bridge, and bind each to its room. The recipient key buzz_channel_id resolves the target Buzz channel UUID at delivery time.

Inbound messages

Each Nostr event is parsed by parse_buzz_event into a RoomKit InboundMessage:

  • Text (kind-9 stream message) → TextContent.
  • The sender's Nostr pubkey becomes sender_id (resolved to a participant).
  • The Nostr event id becomes external_id and idempotency_key.
  • Threads (NIP-10): the thread ROOT event id becomes thread_id (flat two-level model, like Slack's thread_ts), and the immediate parent lands in metadata["nostr_reply_to"].

The agent's own events are always dropped (no echo loop; BuzzConfig.ignore_own=True). Metadata includes nostr_event_id, nostr_kind, and buzz_channel_id.

Reactions

Reactions live outside the message pipeline — the same contract as the Discord and WhatsApp channels. Pass an on_event callback to the source to receive them (this widens the subscription to kinds 9 + 7 + 5):

def on_relay_event(data: dict) -> None:
    # {"action": "add", "emoji": "🔥", "user_id": <pubkey>,
    #  "target_event_id": ..., "reaction_event_id": ..., "channel_id": ...}
    # or {"action": "remove", "user_id": ..., "reaction_event_id": ..., ...}
    ...

source = BuzzRelaySource(config, "buzz-main",
                         relay_channel_id=CHANNEL, on_event=on_relay_event)

A kind-5 deletion is surfaced as action: "remove" with the retracted reaction's event id (Buzz retracts reactions by deleting the reaction event, so the emoji/target are not on the wire). The agent's own reactions are dropped under the same ignore_own policy as messages.

Outbound messages

provider.send(event, to=channel_uuid) publishes RoomEvent.content as a Buzz channel message (kind 9) over the relay's HTTP bridge, signed with the agent's key. It returns a ProviderResult carrying the Nostr event_id. Because sends use the HTTP bridge, they succeed even while the inbound WebSocket is reconnecting.

To reply in a thread, set channel_data.thread_id to the thread-root Nostr event id (the thread_id the inbound parser produced, or the message's own external_id to start a thread under it) — the provider passes it as a NIP-10 reply_to. To react, call provider.send_reaction(target_event_id, emoji); retract with provider.remove_reaction(reaction_event_id) (the id a successful send_reaction returned).

Voice: huddles

Buzz huddles are live voice calls: when someone starts one in a channel, the relay creates an ephemeral channel and announces it on the parent channel (Nostr kind 48100, with the huddle's id in content.ephemeral_channel_id). Audio is 48 kHz mono Opus, one frame per 20 ms, over the relay's /huddle/{id}/audio WebSocket. An agent that is a member of the parent channel is admitted to its huddles automatically.

Two pieces bridge huddles to RoomKit's realtime voice stack:

  • BuzzHuddleBackend — the VoiceBackend that carries huddle audio for a RealtimeVoiceChannel.
  • BuzzHuddleWatcher — the announcement→call lifecycle: watches the parent channel for kind-48100 events (through a BuzzRelaySource with auto_restart, so relay reconnection is handled), dials each huddle, bridges it, and rejoins if the relay drops the call mid-huddle.
from roomkit import RealtimeVoiceChannel, RoomKit
from roomkit.providers.buzz import BuzzConfig
from roomkit.providers.gemini.realtime import GeminiLiveProvider
from roomkit.voice.backends.buzz_huddle import BuzzHuddleBackend, BuzzHuddleWatcher

kit = RoomKit()
voice = RealtimeVoiceChannel(
    "buzz-voice",
    provider=GeminiLiveProvider(api_key="..."),
    transport=BuzzHuddleBackend(),
)
kit.register_channel(voice)
await kit.create_room(room_id="huddles")
await kit.attach_channel("huddles", "buzz-voice")

watcher = BuzzHuddleWatcher(
    kit,
    voice_channel=voice,
    config=BuzzConfig(relay_url="wss://...", private_key="nsec1..."),
    parent_channel_id="<parent-channel-uuid>",
    room_id="huddles",
)
await watcher.start()          # or: await watcher.bridge("<huddle-uuid>")

Buzz-specific behavior to know

  • The relay keeps a huddle alive while any member is connected — the agent included. An agent that never hangs up leaves a zombie huddle behind. The backend therefore ends the session itself when the last remote peer leaves (end_when_alone, default on), with a grace period (empty_huddle_grace, 90 s) for huddles nobody has joined yet.
  • session.metadata["buzz_end_reason"] tells you why a call ended: "alone" (call over) or "connection_lost" (relay dropped the socket). The watcher uses it to decide between rejoining and moving on.
  • The backend resamples internally between the huddle's fixed 48 kHz and the provider's rates (defaults match Gemini Live: 16 kHz in / 24 kHz out). Do not set transport_sample_rate on the channel, or audio is resampled twice at the wrong rates.
  • Silence is streamed toward the provider while the huddle is quiet (silence_fill, default on): huddle senders go silent between utterances (Opus DTX), but a realtime provider's server VAD needs to hear the post-speech silence to close the user's turn.
  • Outbound timing is owned by RoomKit's pacer. The watcher creates huddle clients with paced=False; the backend paces frames with prebuffer and jitter headroom (same OutboundAudioPacer as the SIP backend). If you hand-build a HuddleClient, pass paced=False too.
  • A rejoin starts a fresh provider session — the model does not remember the conversation from before the connection loss.
  • One call at a time: announcements that arrive while a call is bridged are ignored.

For custom announcement handling (e.g. filtering which huddles to join), subscribe your own source with kinds=[KIND_HUDDLE_STARTED] and huddle_announcement_parser, and call watcher.bridge(huddle_id) from your own hook.

Run it as a first-class agent

Buzz expects the same lifecycle promises from every agent, however it was launched: presence that reflects reality, an owner whose !shutdown works, an optional inactivity bound, and intentional stops that stay stopped. BuzzAgent packages them around your configured RoomKit:

from roomkit.providers.buzz import BuzzAgent, BuzzConfig

config = BuzzConfig.from_env()          # BUZZ_PRIVATE_KEY / BUZZ_RELAY_URL / BUZZ_AUTH_TAG
source = BuzzRelaySource(config, "buzz-main", relay_channel_id="<channel-uuid>")
# ... register BuzzChannel, rooms, hooks as above (do NOT attach_source) ...

agent = BuzzAgent(kit, [source], exit_after_inactivity=7200)
cause = await agent.run()               # blocks: owner !shutdown / SIGTERM / idle
sys.exit(0)                             # clean exit — supervisors must not restart it

What each piece does:

  • Owner commands (buzzkit>=0.3.0). With obey_owner_commands=True (default), a kind-9 message whose trimmed content is exactly !shutdown, !cancel or !rotate, mentioning the agent, from the proven owner, is consumed before the pipeline — the AI never answers its own stop command. The owner is the NIP-OA auth tag's attester (Schnorr-verified against the agent's own pubkey), else the explicit BuzzConfig.owner_pubkey. No provable owner, or a non-owner author → the message flows normally (fail-closed). Replay-safe: relays replay recent history on every subscribe, so a command issued before the source started is stale — consumed without action — while one issued during a disconnection is honored when the reconnect replays it. Without a runner, !shutdown stops the source itself; the source's on_owner_command callback takes over the response when provided.
  • Signals. SIGTERM/SIGINT drain through the same graceful path as !shutdown (kit.close() → presence offline → sockets closed).
  • Inactivity (exit_after_inactivity, seconds, default off). No inbound dispatched and no broadcast for that long → the agent reaps itself. The reaper runs on its own timer — deliberately not tied to any other loop — and the knob is deliberately not named like the per-turn timeouts.
  • Finality. run() is single-shot and returns a BuzzAgentStopCause (owner_shutdown / signal / inactivity). Exit 0 on every intentional path, and never deploy the process under Restart=always: restart-on-failure only, so a stop the owner meant stays stopped.

BuzzConfig.from_env() reads the reserved identity triplet every Buzz launcher hands its agents (BUZZ_PRIVATE_KEY/NOSTR_PRIVATE_KEY, BUZZ_RELAY_URL, optional BUZZ_AUTH_TAG), fail-closed — so the same script works under a bash one-liner, a systemd unit, or a container entrypoint.

Capabilities & limits

BuzzChannel advertises text, with threading and reactions. Max message length is 65536 characters (the relay's content limit).

Lifecycle details: a relay closing with code 1012 (graceful restart) is reconnected quietly at the initial backoff — replayed events are deduped by id. Presence is re-announced every 30 s (safe for both the 180 s and the older 90 s relay TTLs), survives transient publish failures, and flips to "offline" on a deliberate stop instead of lapsing by TTL. BuzzConfig.leave_on_stop opts into a NIP-29 leave (kind 9022) when the source stops — leave it off for private channels, where an admin granted the membership and self-join cannot get it back.

Out of scope for now: rich content and media (imeta). Each source subscribes to a single relay channel.

Runnable examples

See examples/buzz_agent.py for a first-class agent (owner !shutdown, signals, inactivity bound, clean exit) driven by the reserved env triplet:

BUZZ_RELAY_URL=wss://your-community.communities.buzz.xyz \
BUZZ_PRIVATE_KEY=nsec1... \
BUZZ_AUTH_TAG='["auth","<owner-pubkey>","","<sig>"]' \
BUZZ_CHANNEL_ID=<channel-uuid> \
uv run python examples/buzz_agent.py

examples/buzz_bot.py is the minimal end-to-end echo bot:

BUZZ_RELAY_URL=wss://your-community.communities.buzz.xyz \
BUZZ_NSEC=nsec1... \
BUZZ_CHANNEL_ID=<channel-uuid> \
uv run python examples/buzz_bot.py

And examples/buzz_voice_agent.py for a speech-to-speech huddle agent (Gemini Live):

GOOGLE_API_KEY=... \
BUZZ_RELAY_URL=wss://your-community.communities.buzz.xyz \
BUZZ_NSEC=nsec1... \
BUZZ_CHANNEL_ID=<channel-uuid> \
uv run python examples/buzz_voice_agent.py