## Summary This change adds two read-only commands to the Buzz CLI. - `buzz huddles list --channel <uuid> [--active] [--limit n]` lists the huddles of a channel. - `buzz huddles participants --channel <uuid> --huddle <id>` shows who is in a huddle now. The commands do not join, start, or end a huddle. They do not touch audio. They do not write to the relay. ## Design The CLI rebuilds huddle state from relay-signed lifecycle events (kinds 48100 to 48103). It uses a Rust port of desktop's `HuddlePresenceTracker`. The CLI and the app therefore agree on rosters. The port follows these desktop rules: - Only the relay can sign a join or a leave. - Only the relay or the huddle creator can end a huddle. - Events replay in desktop's order: time, then start, join/leave, end, then relay roster revision, then event id. - Each admission has an ID. A late leave for an old admission does not remove a rejoined participant. - A join from a newer generation replaces the older roster. A leave from an older generation is ignored. A huddle has one of three states: - `active`: no end event, and the start is less than one hour old. - `ended`: a valid end event exists. - `stale`: no end event, and the start is more than one hour old. Desktop shows this state as ended. The CLI keeps it separate because no end event proves it. Every query names the four lifecycle kinds and the `#h` channel. A command scans at most 5000 newest events. The output always has `complete`, `truncated`, `events_scanned`, and `event_bound`. - If the huddle start is inside the scan, the roster is exact. `complete` is `true`. - If the start is not inside the scan, `participants` returns `state: "unknown"` and `complete: false`. It does not return a partial roster. - A huddle that does not exist, with no truncation, returns exit code 1. Deviations from desktop and from CLI convention: - The HTTP `/query` endpoint does not serve liveness events (kind 48104). Only the WebSocket REQ path creates them. The CLI cannot check liveness. Every output has `liveness_checked: false`. Generations are never authoritative in the CLI. - Desktop keeps the roster in a Set, so its order is not defined. The CLI sorts by join time, then by pubkey. - Desktop also adds backing-channel members as a fallback roster. The CLI does not. It reports only what the lifecycle events show. - Desktop's roster view lists the creator from the start event. The CLI lists the creator only after the relay records their join. Desktop's roster view also uses a second event comparator. The CLI uses the presence tracker's comparator. - The commands return one JSON object, not a bare array. The object needs room for the truncation fields. Silent truncation would break Rule 1. The change also updates `crates/buzz-cli/README.md`. This PR does not change `desktop/` (deprecated; see #8840). ## Testing What ran: - `cargo test -p buzz-cli -p buzz-sdk`: 518 and 322 tests passed. - `cargo clippy -p buzz-cli -p buzz-sdk --all-targets -- -D warnings`: clean. - `cargo fmt --all --check`: clean. - `just file-size-check`: clean. The new tests call the production functions. A test also checks exit codes for relay-info failures: a 500 and an unreachable relay exit 2, and a 403 exits 3. They parse arguments, build output from events, and run `run_list` and `run_participants` against a local fake relay. The fake relay serves NIP-11 and `/query` with the same cursor paging as the real bridge. The tests cover rejoin, leave without join, ended huddle, stale generation, bad end signer, forged join, same-second ordering, and the event bound. Interop evidence: - `crates/buzz-cli/tests/fixtures/huddle_sequences.json` holds 14 recorded event sequences. - The Rust tests check each sequence against the expected roster and ended state. - `crates/buzz-cli/tests/huddle_interop.mjs` replays the same file through desktop's own `HuddlePresenceTracker` under node. All 14 sequences pass with `INTEROP OK`. - Run it with: `node --import ./desktop/test-loader.mjs --experimental-strip-types crates/buzz-cli/tests/huddle_interop.mjs` Mutation checks. I broke each guard once and confirmed a test failed. I then restored the code. - Relay-only author check on join and leave. - End signer check. - Ignore events after an end. - Roster revision comparison. - Ignore a leave from an older generation. - One-hour stale window. - Tie rule for the oldest scanned second. - Unknown state on truncation. - The truncation flag. - Kinds in the query filter. - `--active` filter. - Required relay `self` pubkey. All 12 mutations failed at least one test. A 13th mutation, which wraps relay-info errors as `Other`, also fails a test. No live relay was used. No event was written. ## Manual test steps You need a relay URL, a key, and a channel UUID where someone has run a huddle. 1. Build the CLI: `cargo build --release -p buzz-cli` 2. Set `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY`. 3. Run `./target/release/buzz huddles list --channel <uuid>`. - Success: the output is one JSON object with a `huddles` array. Newest huddle is first. Each huddle has `state`, `started_at`, and `ended_at`. `complete` is `true`. 4. Start a huddle in the desktop app. Run `./target/release/buzz huddles list --channel <uuid> --active`. - Success: the new huddle appears with `state: "active"`. 5. Copy its `huddle` value. Run `./target/release/buzz huddles participants --channel <uuid> --huddle <huddle-id>`. - Success: `participants` lists the same people as the desktop huddle window. 6. Have one person leave, then run the command again. - Success: that person is gone from `participants`. 7. End the huddle. Run the `participants` command again. - Success: `state` is `ended`, `ended_at` has a time, and `participants` is empty. 8. Run the `participants` command with a random UUID as `--huddle`. - Success: the command exits with code 1 and prints a not-found error. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: bmcanally <bmcanally@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Buzz CLI
Agent-first command-line interface for Buzz relay. JSON in, JSON out.
Install
cargo install --path crates/buzz-cli
Authentication
| Env Var | Mode | Use Case |
|---|---|---|
BUZZ_PRIVATE_KEY |
NIP-98 Schnorr signature | Agents with a keypair |
# Private key identity (NIP-98 signed requests)
export BUZZ_PRIVATE_KEY="nsec1..."
buzz channels list
Usage
All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict.
# Set relay URL (defaults to http://localhost:3000)
export BUZZ_RELAY_URL="https://relay.example.com"
# Messages
buzz messages send --channel <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages thread --link 'buzz://message?channel=<uuid>&id=<event-id>&thread=<root-id>'
buzz messages search --query "architecture"
buzz messages search --author <pubkey|npub|name> --since <unix-ts>
buzz messages edit --event <event-id> --content "Updated text"
buzz messages delete --event <event-id>
# Diffs
buzz messages send-diff --channel <uuid> --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch
# Channels
buzz channels list
buzz channels create --name "my-channel" --type stream --visibility open
buzz channels join --channel <uuid>
buzz channels topic --channel <uuid> --topic "New topic"
# Reactions
buzz reactions add --event <event-id> --emoji "👍"
buzz reactions get --event <event-id>
# Huddles (read-only)
buzz huddles list --channel <uuid> --active # huddles with state and start/end times
buzz huddles participants --channel <uuid> --huddle <huddle-id>
# GIFs (requires relay to advertise buzz-gif / KLIPY)
buzz gifs search # trending GIFs
buzz gifs search --query "celebration" # search GIFs
buzz gifs share --slug <slug> # report selection to provider Recents
# Paste the `cdn_url` from a search result directly into messages send --content
# Custom emoji in messages
# buzz messages send scans outgoing content for :shortcode: patterns and
# automatically attaches NIP-30 ["emoji", shortcode, url] tags from the
# workspace palette — identical to the desktop composer behavior.
# Users & Presence
buzz users get # your own profile
buzz users get --pubkey <hex> # single user
buzz users get --pubkey <hex> --pubkey <hex> # batch (max 200)
buzz users get --name Honey --owner me # exact-name lookup in your managed agents
buzz users set-presence --status online
buzz users set-status --text "heads down on the CLI" --emoji "🚀"
buzz users set-status --clear # remove your status
# DMs
buzz dms open --pubkey <hex>
buzz dms list
# Workflows
buzz workflows list --channel <uuid>
buzz workflows trigger --workflow <uuid>
buzz workflows approve --token <uuid>
buzz workflows approve --token <uuid> --approved false --note "needs revision"
# Forum
buzz messages vote --event <event-id> --direction up
# Canvas
buzz canvas get --channel <uuid>
buzz canvas set --channel <uuid> --content "# Welcome"
# Agent Memory (NIP-AE)
buzz mem ls
buzz mem get <slug>
buzz mem set <slug> "my-value"
buzz mem patch <slug> --base-hash <hex> < diff.patch # or --no-base-hash
buzz mem rm <slug>
# Repository protection
buzz repos protect list --id my-repo
buzz repos protect set --id my-repo --ref refs/heads/main --push admin --no-force-push --no-delete
buzz repos protect remove --id my-repo --ref refs/heads/main
# Pipe to jq
buzz channels list | jq '.[].name'
protect set replaces every existing rule for the exact ref pattern. Any
constraint omitted from the command is removed. protect list reports malformed
stored rules in validation_error so an owner can remove and repair them.
Commands
buzz --help prints this whole surface as an indented tree — every group with
its subcommands and their descriptions — so there is no need to run --help
once per group to find a command. buzz -h keeps the short group-level
summary, and buzz <group> <subcommand> --help has the flags and examples.
The table below mirrors that tree for readers who are not at a terminal.
| Group | Subcommand | Description |
|---|---|---|
messages |
send |
Send a message to a channel |
send-diff |
Send a code diff with metadata | |
edit |
Edit a message you sent | |
delete |
Delete a message | |
get |
List messages in a channel | |
thread |
Get a message thread | |
search |
Full-text search, filterable by author | |
vote |
Vote on a forum post | |
channels |
list |
List channels |
get |
Get channel details | |
create |
Create a channel | |
update |
Update channel name/description | |
topic |
Set channel topic | |
purpose |
Set channel purpose | |
join |
Join a channel | |
leave |
Leave a channel | |
archive |
Archive a channel | |
unarchive |
Unarchive a channel | |
delete |
Delete a channel | |
members |
List channel members | |
add-member |
Add a member | |
remove-member |
Remove a member | |
canvas |
get |
Get channel canvas |
set |
Set channel canvas | |
reactions |
add |
React to a message |
remove |
Remove a reaction | |
get |
List reactions | |
huddles |
list |
List a channel's huddles (read-only) |
participants |
Show who is in a huddle (read-only) | |
gifs |
search |
Search or browse trending GIFs (requires relay buzz-gif support) |
share |
Report a selected GIF to the provider's Recents | |
dms |
list |
List DM conversations |
open |
Open a DM (1–8 pubkeys) | |
add-member |
Add member to DM group | |
users |
get |
Get user profile(s) |
set-profile |
Update your profile | |
presence |
Get presence status | |
set-presence |
Set presence status | |
set-status |
Set or clear your NIP-38 profile status | |
workflows |
list |
List workflows |
get |
Get workflow definition | |
create |
Create a workflow | |
update |
Update a workflow | |
delete |
Delete a workflow | |
trigger |
Trigger a workflow | |
runs |
Get workflow run history | |
approve |
Approve/deny a workflow step | |
feed |
get |
Get your activity feed |
social |
publish |
Publish a NIP-01 note |
set-contacts |
Set NIP-02 contact list | |
event |
Get a Nostr event | |
notes |
Get notes for a user | |
contacts |
Get NIP-02 contact list | |
repos |
create |
Announce a git repository (NIP-34) |
get |
Get a repository announcement | |
list |
List repository announcements | |
default-branch get/set |
Read or select an existing default branch (requires relay support) | |
protect list |
List branch and tag protection rules | |
protect set |
Create or replace a protection rule | |
protect remove |
Remove a protection rule | |
upload |
file |
Upload a file to the Blossom store |
pack |
validate |
Validate a persona pack (local, no relay) |
inspect |
Inspect a persona pack (local, no relay) | |
mem |
ls |
List non-tombstoned memories |
get |
Print memory value to stdout | |
hash |
Print SHA-256 hex of memory value | |
set |
Write a memory value (use - for stdin) |
|
patch |
Apply unified diff to memory value | |
rm |
Publish a tombstone to delete memory |
Huddles
huddles list and huddles participants rebuild huddle state from the relay-signed lifecycle
events (kinds 48100–48103) with the same rules as the desktop app. They never join, start, or end
a huddle. Each scans at most 5000 newest events of the channel. If older events may be missing,
complete is false and truncated is true. A huddle with no end event is active, then
stale once its start is over an hour old; desktop shows stale as ended. The relay's liveness
snapshots are not served over HTTP, so liveness_checked is always false.
Rosters come from the relay's join and leave events only. Desktop's roster view also lists the
creator from the start event; the CLI lists the creator once the relay records their join.
Architecture
buzz <group> <subcommand> [flags]
│
├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Buzz Relay REST API
│ (clap) (handlers) (reqwest)
│
├─ validate.rs (UUID, hex, content size, percent-encode)
└─ error.rs (CliError → JSON stderr + exit code)
stdout: raw relay JSON
stderr: {"error": "category", "message": "detail"}
exit: 0=ok 1=user 2=network 3=auth 4=other 5=write conflict
Default branch
After deploying relay support, select an existing published branch without renaming or deleting any branch:
buzz repos default-branch get --owner <owner-hex> --id my-repo
buzz repos default-branch set --owner <owner-hex> --id my-repo --branch main
# For an explicitly reviewed version, use the manifest digest returned by get:
buzz repos default-branch set --owner <owner-hex> --id my-repo --branch main \
--expected-manifest <manifest-digest>
--owner defaults to the signing identity, not an agent's attested human owner.
set without --expected-manifest reads the current version first. Success
returns branch, head, manifest and changed; get omits changed.
A stale version returns conflict (exit 5). Ambiguous write failures return
delivery_unknown with retryable:false and the original digest: read before
retrying, and do not blindly re-run against a newly fetched version.
The signer must be a current channel member and a repository manager, directly or through an unrestricted, valid NIP-OA owner attestation; permission to push is not permission to change the default. See the protocol and authorization contract.