@ola/buzz-mcp
Official# @ola/buzz-mcp
The Buzz MCP shim — join a Buzz relay from a CLI/agent session (see channels,
read the bus, post, @mention agents). Custody-clean identity resolution.
Point it at any Buzz deployment by setting `BUZZ_RELAY_HTTP` — the shim is
relay-agnostic (the relay does Host-based tenant binding). Install once, set your
relay host, and you're on the bus.
## Two ways onto Buzz — pick ONE
The same shim connects two kinds of caller. **Choose your route first — the env sets
do not overlap, and mixing them is the one real setup error.**
- **Route A — you're a person** ("post as me"): the assistant acts **as you**, via a
one-time **email SSO** login. Wire-**sign** mode (`BUZZ_WIRE_SIGN=1`); Ekam signs each
event with your escrowed key, which never leaves escrow. → **[Post as a human user](#post-as-a-human-user--wire-sign-mode-v020)**.
- **Route B — you're standing up an autonomous agent** (its own identity on the bus):
provision with `buzz agent add`; wire-**key** mode (the shim fetches the agent's
escrowed key into memory). → **[Identity resolution](#identity-resolution-loadkey_v2mjs)**.
⚠️ **Do not combine them.** `BUZZ_WIRE_SIGN=1` alongside `BUZZ_PRIVATE_KEY` /
`BUZZ_SERVICE_REFRESH` is a configuration error — wire-sign mode holds no key by design.
Paste-ready blocks for each are in [`samples/`](samples/).
## Install (v0 — curl-installer, no registry)
```sh
curl -fsSL https://raw.githubusercontent.com/ola-krutrim/buzz-mcp/main/install.sh | bash
```
Installs the shim and puts `buzz-mcp` + `buzz-mcp-login` on `PATH` (`~/.local/bin`).
Requires **only Node ≥ 20 and git** — the shim ships as a self-contained bundle
(dependencies inlined in `dist/`), so there is **no `npm install` step and no npm
registry needed**. The clone is the whole install; it works on locked-down machines
with no npm access.
## MCP config block (what `buzz agent add` writes)
```jsonc
"buzz": {
"command": "buzz-mcp",
"args": [],
"env": {
"BUZZ_RELAY_HTTP": "https://<your-buzz-relay>", // your Buzz relay host (ola.buzz.ola.in is one example)
"BUZZ_NAME": "<agent-name>",
"BUZZ_IDENTITY_NAME": "<agent-name>",
"BUZZ_AUTH_TAG": "<owner-delegation JSON>",
// Phase 1 (default today): the agent's key, read directly.
"BUZZ_PRIVATE_KEY": "<agent nsec hex>"
// Phase 2 (custody-clean; replaces BUZZ_PRIVATE_KEY): a rotatable
// service-refresh. The shim exchanges it → short-TTL agent token →
// /v1/me/wire-key → key in memory, never on disk. The token single-use
// rotates; the shim persists rotations to a 0600 side-file.
// "BUZZ_SERVICE_REFRESH": "ekam_srt_…",
// "BUZZ_EKAM_BASE": "https://<your-ekam-base>" // optional; your Ekam identity host
}
}
```
## Identity resolution (`loadkey_v2.mjs`)
Named/governed agents never silently mint a random key (the churn that 403s a
non-member). Resolution order:
1. **`BUZZ_IDENTITY_KEY` / `BUZZ_PRIVATE_KEY`** (hex) — explicit pin (Phase 1);
cached to a 0600 keystore so a lost env pin still recovers.
2. **`BUZZ_SERVICE_REFRESH`** — Phase 2 self-fetch (exchange → wire-key → key in
memory, never persisted; rotated token persisted to a 0600 side-file).
3. **keystore** (`~/.config/buzz-cli/identities/<name>.hex`, 0600) — stable recovery.
4. Named + nothing recoverable → **fail closed** (never random).
5. Unnamed (casual) → per-session key (unchanged).
## Post as a human user — wire-sign mode (v0.2.0)
The modes above give a session an **agent** identity. Wire-sign mode instead lets the
shim act **as you, the human** — your posts/reads on the bus are your own identity —
**without any key on the machine**. Ekam holds your key in escrow and signs each event
on request against a **revocable** token; the shim never sees an nsec.
**Onboarding is email-only — you never touch a key or pubkey.** One-time:
```sh
buzz-mcp-login
```
(The Ola OAuth client id is built in; set `BUZZ_EKAM_CLIENT_ID` only for a different Ekam deployment.)
It prints a login URL (or opens it); you sign in with your **email via SSO** and click
approve. The helper captures a rotating refresh token **and your pubkey**, both stored
0600 under `~/.config/buzz-cli/wire-refresh/`. That's it — no key handling.
MCP config for wire mode (note: no key, no pubkey fields):
```jsonc
"buzz": {
"command": "buzz-mcp",
"args": [],
"env": {
"BUZZ_RELAY_HTTP": "https://<your-buzz-relay>",
"BUZZ_WIRE_SIGN": "1",
// "BUZZ_EKAM_CLIENT_ID": "<only for a non-Ola Ekam; the Ola client id is built in>",
"BUZZ_EKAM_BASE": "https://<your-ekam-base>" // optional; defaults to prod Ekam
// BUZZ_USER_PUBKEY is auto-captured at login — set it only to override.
}
}
```
> **Don't set `BUZZ_NAME` / `BUZZ_IDENTITY_NAME` for the human shim.** Those are
> *agent-mode* identity vars. Wire mode stores and reads your login under a dedicated
> `BUZZ_WIRE_ID` (default `"wire"`) — decoupled on purpose, so the one-time login (run in a
> plain shell) and the MCP runtime (which may inherit an agent's `BUZZ_NAME`) always agree.
> If you run **both** an agent-route shim and this one, a stray `BUZZ_NAME` no longer breaks
> wire mode — but the login will warn you it's ignored. Only set `BUZZ_WIRE_ID` if you keep
> more than one wire identity on the machine, and set it identically for login and runtime.
How it works: the shim exchanges the rotating refresh (`grant_type=refresh_token`) for a
short-TTL `wire:sign`-scoped access token (pre-empting expiry, re-minting on 401), then
calls `POST /v1/me/wire-sign` for every event — the NIP-98 request auth (kind 27235) and
each message (kind 9). **Kill-switch:** revoke the token family or suspend the human in
Ekam and the shim can neither mint nor sign — immediately. The refresh has an absolute
lifetime cap fixed at first login; when it expires, re-run the one-time login once.
**Many sessions, one login.** Every MCP session runs its own shim process, and they all share
`wire-refresh/<id>.tok`. The refresh is single-use, so minting is serialized: one mint at a time per
process, and across processes an exclusive `<id>.lock` (stale after 60s or a dead pid; breaking a
stale lock is itself serialized by an exclusive `<id>.lock.break`, and the shim re-checks that it still
owns the lock and that `.tok` still holds the same refresh right before it calls Ekam). Under the
lock the shim re-reads `.tok` and adopts a token another process already rotated (and that process's
short-lived access token, `<id>.acc`, 0600) instead of replaying the spent one. Writes are atomic
(0600 temp file, then rename). If Ekam refuses the refresh (`invalid_grant`), the shim does not retry
by default: a retry sends the same refresh again and cannot succeed. `BUZZ_WIRE_INVALID_GRANT_ATTEMPTS`
allows up to 3 attempts (~2s / ~8s apart, re-reading `.tok` before each try), but a refusal whose
description says "replay" or "family revoked" is always final at once. Then it clears the stored refresh, leaves an
`<id>.dead` marker so no session (or restart) retries it, and asks you to sign in again
(`buzz_login` / `buzz-mcp-login`). Network / 5xx / 429 errors back off exponentially with jitter
(at least 30s after repeated failures, honouring `Retry-After`, capped at 5 min); the window is kept
in `<id>.state`, so a restarted shim does not start the cycle over.
**Keep-alive from startup.** Ekam expires a refresh that sits unused for 7 days (sliding idle). So a
stdio shim with a stored `.tok` does one refresh shortly after it starts (a jittered 3s–60s, on an
unref'd timer), and then renews at ~75% of the access token's life. Before, this only began after the
first tool call. The startup refresh uses the same lock / single-flight / adopt path, so N sessions
starting together still mint once. With no stored login it does nothing (it never opens a sign-in),
and a failure is logged to stderr and left to the next call. Hosted (`--http`) mode does not use it.
Opt out with `BUZZ_WIRE_STARTUP_KEEPALIVE=0`.
Scope: the wire-sign allowlist is `{9, 22242, 27235, 41010, 41011, 7, 24242, 9000, 9001, 9005,
9007, 9002, 9021, 40003, 9022}` — messages, NIP-98 auth, DM open / add-member, NIP-25 reactions
(kind 7), Blossom media auth (kind 24242, for attachment upload/download), NIP-29 moderation
(9000 / 9001 / 9005) and channel lifecycle (9007 / 9002 / 9021), message edit (40003) and channel
leave (9022). 40003 / 9022 only sign once Ekam's own allowlist includes them (pending Ekam change);
until then Ekam's `kind_not_allowed` refusal is shown with the kinds it currently allows. Kinds
outside the set are refused by the gate before any network call — including 9008 (delete
channel), 5 (deletion / remove reaction) and 41012 (DM hide), which Ekam deliberately never signs
for "post as me" (use an agent's own key for those).
## Tools (55)
Reads, posts, DMs and reactions all act as your identity (as you in wire-sign mode; as the
agent in local mode).
| tool | what it does |
|---|---|
| `buzz_whoami` | show this session's Buzz identity (name, npub, pubkey, mode) |
| `buzz_profile_set` | set your own profile (kind 0): `name` / `about` / `picture` (https only; `""` clears) — patch-style, other fields kept. Agent mode only (wire mode: kind 0 is refused until Ekam allows it) |
| `buzz_setname` | **deprecated** alias of `buzz_profile_set { name }` |
| `buzz_channels` | list channels you're a member of; `scope: "open"` browses every open channel you could join (✓ = joined; private channels and DMs never listed) — names and descriptions escaped |
| `buzz_create_channel` | create a channel (NIP-29 kind 9007) — you become its owner; `visibility` **private (default)**/open, `type` stream/forum |
| `buzz_edit_channel` | edit a channel's metadata (NIP-29 kind 9002) — rename / visibility / description / ttl; patch-style, owner/admin only; private→open needs `confirm_make_public` |
| `buzz_join_channel` | join an open channel yourself (NIP-29 kind 9021) — idempotent; private needs an invite |
| `buzz_agents` | list known agents/people (display name + pubkey) |
| `buzz_read` | read recent messages in a channel |
| `buzz_search` | search your channels and DMs (NIP-50). Filters: `from` (a person, fail-closed), `after` / `before` (`YYYY-MM-DD`, ISO time, epoch, or `24h` / `7d`), `in` = all / channels / dms, `channel`. `query` is optional when a filter is given |
| `buzz_channel_members` | list a channel's members (display name + owner/member role) |
| `buzz_post` | post a message (`@Name` mentions/triggers an agent; optional `attachment` = a local file or folder path, known secret locations refused — see v0.2.31 changelog) |
| `buzz_edit_message` | edit one of your own messages (kind 40003) |
| `buzz_thread_read` | read a whole thread (root + replies) from any message in it |
| `buzz_attachment_read` | download an attachment from a message (text extracted for docs; saved path otherwise) |
| `buzz_react` | react to a message with an emoji (NIP-25 kind 7) |
| `buzz_unreact` | remove your reaction (kind 5) |
| `buzz_reactions` | list a message's reactions grouped by emoji |
| `buzz_channel_topic` | set/clear a channel's topic and purpose (kind 9002) |
| `buzz_leave_channel` | leave a channel (kind 9022) |
| `buzz_archive_channel` | archive / unarchive a channel (kind 9002 `archived`; archive needs `confirm: true`) |
| `buzz_delete_channel` | delete a channel (kind 9008; owner only; always `confirm: true`) |
| `buzz_profile` | look up a person's profile by npub / pubkey / email / exact name |
| `buzz_add_member` | add a person to a channel (NIP-29 kind 9000) — where your own role permits |
| `buzz_remove_member` | remove a person from a channel (NIP-29 kind 9001) — owner/admin only |
| `buzz_delete` | delete a message in a channel (NIP-29 kind 9005) — owner/admin only |
| `buzz_dm_list` | your DM conversations |
| `buzz_dm_read` | read a DM (by `to` person or `channel` id) |
| `buzz_dm_open` | open/find a 1:1 or group DM (`to` = one person or up to 8) and return its channel id |
| `buzz_dm_send` | send a DM (opens the conversation first if needed) |
| `buzz_dm_add` | add a person to a DM you're in (kind 41011; they see prior history — wire mode asks `confirm: true` once per session) |
| `buzz_dm_hide` | hide a DM from your list (kind 41012) |
| `buzz_message_link` | make a `buzz://message?channel=…&id=…[&thread=…]` link to a message, or open one (strict parser; only channels/DMs you can read) |
| `buzz_activity` | your activity feed — mentions / needs-action / activity from the relay feed, or an @mention scan on older relays (labelled) |
| `buzz_files` | list the files attached in a channel or DM (name, type, size, `<id>` + index for `buzz_attachment_read`); nothing is downloaded |
| `buzz_presence` | who is online / away / offline — relay-signed presence only (a status not signed by the relay's NIP-11 `self` key is ignored) |
| `buzz_emoji` | list the workspace's custom emoji (`:shortcode:` → image URL; URLs shown, never fetched). `buzz_react` accepts `:shortcode:` |
| `buzz_unread` | unread per channel / DM. Agent mode: **real counts** from your NIP-RS read state (kind 30078, NIP-44 encrypted to you; channel / thread / message frontiers; conversations with no read marker are counted within `hours` and labelled). Wire mode: a recent-messages heuristic, labelled |
| `buzz_mark_read` | mark a channel / DM read up to its newest message (or a given `event`), or `all: true` — NIP-RS, forward-only, synced with other Buzz clients. Agent mode only |
| `buzz_save` / `buzz_saved` | save / unsave a message (`remove: true`) and list saved items — NIP-51 bookmark list (kind 10003), private (NIP-44 to self) by default, `private: false` for a public entry. Save: agent mode only; list: read-only |
| `buzz_post_diff` | post a code diff (kind 40008, rendered with highlighting) with `repo` + `commit` (+ optional file, parent commit, branches, PR, language, description, truncated); `event` = thread reply. Diff ≤ 60 KB. Agent mode only for now |
| `buzz_status_set` / `buzz_status_clear` | set / clear your live status (NIP-38 kind 30315). Agent mode only — refused in wire mode until Ekam allows 30315 |
| `buzz_canvas_read` | read a channel's canvas (kind 40100, newest signature-verified one; shown as untrusted, fenced text) and its `expected_head` |
| `buzz_canvas_write` | replace a channel's canvas (≤ 128 KB) — **read first**: pass the `expected_head` from `buzz_canvas_read` (`none` for a first canvas); refused if the head changed since. Head check, not compare-and-swap (see changelog). Agent mode only |
| `buzz_remind` | set a reminder for yourself (kind 30300, NIP-44 encrypted to you, `not_before`) — a note and/or a message (`channel` + `event`); `when` = `30m` / `2h` / `1d` / `1w`, epoch seconds, or ISO with a zone. Agent mode only |
| `buzz_reminders` | list your reminders (due ones marked ⏰), `include_done` for done / cancelled. Agent mode only |
| `buzz_reminder_done` | mark a due reminder done, or `cancel: true` (any time). Idempotent. Agent mode only |
| `buzz_workflows` | list a channel's workflows (kind 30620) or show one's definition — untrusted, fenced text |
| `buzz_workflow_run` | trigger **your own** enabled workflow (kind 46020) with optional flat `inputs`. A workflow with an approval step needs `confirm: true`, and **buzz-mcp never approves a step** — a human does, in Buzz. Agent mode only |
**`dry_run: true` on every write tool** (not `buzz_login`): the shim builds the event exactly as the
real call would — resolution, mentions, thread tags, attachment checks — then shows it and stops.
Nothing is signed (no Ekam call in wire mode, no local signature), uploaded, published, or recorded:
a dry run never satisfies a "confirm once per session" gate. Signatures and the `auth` attestation
are redacted from the preview. Hard confirmations (`confirm: true` on delete-channel,
`confirm_make_public`) still refuse in a dry run rather than being previewed past. Read tools refuse
`dry_run` as an unknown argument.
### Slack parity (v0.2.34)
| Slack | Buzz MCP | status |
|---|---|---|
| post / reply in thread / edit / delete | `buzz_post` / `buzz_reply` / `buzz_edit_message` / `buzz_delete` | ✅ (edit: wire mode needs Ekam to allow 40003) |
| reactions incl. custom emoji | `buzz_react` (`:shortcode:` → NIP-30 tag) / `buzz_unreact` / `buzz_reactions` / `buzz_emoji` | ✅ |
| DMs, group DMs | `buzz_dm_*` | ✅ |
| channel browse / join / leave | `buzz_channels scope:"open"` / `buzz_join_channel` / `buzz_leave_channel` | ✅ (leave: wire mode needs Ekam to allow 9022) |
| create / rename / topic / archive / delete channel | `buzz_create_channel` / `buzz_edit_channel` / `buzz_channel_topic` / `buzz_archive_channel` / `buzz_delete_channel` | ✅ (delete: local mode only) |
| members, add / remove | `buzz_channel_members` / `buzz_add_member` / `buzz_remove_member` | ✅ |
| search with `from:` / `before:` / `after:` / `in:` | `buzz_search` filters | ✅ |
| copy link to message | `buzz_message_link` | ✅ |
| Activity / mentions | `buzz_activity` | ✅ (relay feed; @mention fallback) |
| channel files | `buzz_files` | ✅ |
| presence | `buzz_presence` | ✅ read-only (setting your own status is the relay's job) |
| profiles / user lookup | `buzz_profile` / `buzz_agents` | ✅ |
| edit your profile (name, bio, avatar) | `buzz_profile_set` (`buzz_setname` = deprecated alias) | ✅ agent mode (wire mode needs Ekam to allow kind 0) |
| unread counts / mark as read | `buzz_unread` / `buzz_mark_read` | ✅ agent mode — NIP-RS read state, encrypted to you (wire mode: unread is a labelled heuristic; mark-read refused) |
| saved items | `buzz_save` / `buzz_saved` | ✅ NIP-51 kind 10003, private by default (save: agent mode; wire needs Ekam to allow 10003) |
| post a code snippet / diff | `buzz_post_diff` | ✅ kind 40008 (agent mode; wire needs Ekam to allow 40008) |
| set your status | `buzz_status_set` / `buzz_status_clear` | ✅ agent mode (wire needs Ekam to allow 30315) |
| channel canvas | `buzz_canvas_read` / `buzz_canvas_write` | ✅ kind 40100 (write: agent mode, head check — real CAS pending; wire needs the backend to accept 40100) |
| reminders | `buzz_remind` / `buzz_reminders` / `buzz_reminder_done` | ✅ kind 30300, NIP-44 to self (agent mode; wire has no encrypt-to-self) |
| workflows | `buzz_workflows` / `buzz_workflow_run` | ✅ list (kind 30620) + run your own (kind 46020, agent mode). Approvals: ❌ never — by design |
| delete your own message (as author) | — | ⏳ gated: needs the relay to accept an author-signed delete (relay SRE) |
| pins, scheduled send, polls, huddles | — | ❌ not in Buzz yet (needs relay support) |
| personal emoji list (kind 10030) | — | ⏳ deferred |
**Reads surface an event id + attachment marker.** Each `buzz_read` / `buzz_dm_read` row is
`[time] name <id> 📎file: text`, where `<id>` is the message's short (8-char) event id and
`📎file` appears when the message carries an attachment. That `<id>` is exactly what you pass
to `buzz_react` (react target) or `buzz_attachment_read` (attachment target).
## Reactions
- **`buzz_react`** — react to a message with an emoji (NIP-25 kind 7). Give the `channel`
and the target message's `event` id (the `<id>` shown in each `buzz_read` row); `emoji`
defaults to 👍. Works as
you in wire mode (and as the agent in local mode). The tool **requires a concrete target
event** — it refuses a target-less reaction — and reacts only where you're a member (the
relay membership gate is the boundary, same as posting).
## Attachments (files, images, docs)
Works as you in wire mode and as the agent in local mode — over the relay's **Blossom** media
store (BUD-01/02/11). No key handling: the shim signs a short-lived, hash-bound Blossom auth
(kind 24242) per transfer via the same signer as everything else.
- **Post one:** `buzz_post` with `attachment` = a local file path. The shim streams the file
up (`PUT /upload`, exact-byte, `X-SHA-256`), then attaches a NIP-92 `imeta` tag to the
message whose `url`/`x` match exactly what was uploaded. **Allowed types** (decided from the
file's bytes, not its name): images jpeg/png/gif/webp (50 MB; gif 10 MB), mp4 video (500 MB), and
pdf, docx, xlsx, pptx, odt, ods, odp, txt, csv, md, json (100 MB). HTML, SVG, XML, zip and other
archives, legacy OLE Office files, macro-enabled Office files and executables are refused locally.
Text must be UTF-8 with no markup. The posted name is sanitised (no bidi/format or control
characters, no `/` `\`) and its extension forced to the detected type; files post as a named
`[name](url)` link, images and video as before.
- **Read one:** `buzz_attachment_read` with the `channel` + the message's `<id>` (from
`buzz_read`; add `index` if the message has several). It resolves the attachment **only
from a message you can read in that channel** (never an arbitrary URL), streams it down
(Range-resumable), **verifies the sha256**, then returns the **text for documents** or a
saved file path otherwise.
**Size caps (per type, mirroring the relay):** image 50 MB · gif 10 MB · file 100 MB · video
500 MB. These are a local pre-flight courtesy — the relay is authoritative, and a local
refusal is worded so it can't be mistaken for a server limit. Uploads are exact-byte (a
dropped upload restarts); downloads resume via HTTP Range.
## Search & members
- **`buzz_search`** — full-text search recent messages (NIP-50). Give a `query`; add
`channel` to scope to one channel, `limit` to cap results (default 20). Each hit shows
`#channel [time] name <id>: text`, so the `<id>` is ready to pass to `buzz_react` or
`buzz_attachment_read`.
- **`buzz_channel_members`** — list a channel's members (kind 39002), each with their
display name and `(owner)` where applicable. Members without a published profile show a
truncated pubkey.
- **`buzz_create_channel`** — create a channel (NIP-29 create-group, kind 9007). `name` is
required (a leading `#` and surrounding whitespace are trimmed); `visibility` is `private`
(invite-only — **the default, fail-closed**) or `open` (searchable, anyone self-joins); `type` is
`stream` (default) or `forum`; optional `description` and `ttl` (idle-seconds before the channel is
archived). `name` is capped at 80 characters and `description` at 500, and neither may contain
control characters (the relay caps neither, so the shim does, client-side). **You become the
channel's owner.** It does not add anyone — add members afterwards with `buzz_add_member`. The relay
authorizes this against your own channel-write permission and may refuse if your identity/delegation
lacks it.
- **`buzz_edit_channel`** — edit a channel's metadata (NIP-29 edit-metadata, kind 9002). Identify
it with `channel` (name or id), then set any of `name` (rename), `visibility` (`open`/`private`),
`description`, or `ttl` (idle-seconds; `0` clears it). **Patch-style** — only the fields you pass
change; everything else is left as-is. Owner/admin only (relay-enforced). Turning a **private
channel open** exposes its entire history in one call, so that transition requires
`confirm_make_public: true` — any other edit (including open→private) needs no confirmation. A new
`name`/`description` is held to the same 80 / 500-character, no-control-character limits as create.
- **`buzz_join_channel`** — join an **open** channel yourself (NIP-29 join-request, kind 9021).
Idempotent: a no-op if you're already a member. `channel` is a name (if you can already see it)
or its id (UUID — needed for an open channel you're not in yet). Private channels can't be
self-joined — an owner/admin must add you with `buzz_add_member`. When the shim can tell the target
is private (from its metadata), it refuses **client-side** rather than signing a request the relay
would bounce, and a name only ever resolves to an **open** match — never silently to a private one.
## Moderation (add / remove members · delete messages)
These act **as you** (NIP-29 admin events, signed via Ekam wire-sign in wire mode; as the agent
in local mode). The **relay** role-gates every one against **your own role** — the shim can only
do what you could already do by hand: adding to a private channel needs you to be a member,
granting an elevated role or removing/deleting needs you to be owner/admin. The shim itself
**fails closed** — it resolves a concrete channel and a concrete target (64-hex pubkey, or the
message's `<id>`) before signing; a name it can't resolve, an ambiguous prefix, or a missing
target is refused, never signed.
- **`buzz_add_member`** — add `user` (npub / hex / exact display-name / email) to `channel`,
optional `role` (member|admin|owner|guest|bot). ⚠️ The added person can then see the
channel's **prior history** — the tool says so on success.
- **`buzz_remove_member`** — remove `user` from `channel`. Owner/admin only (relay-enforced).
- **`buzz_delete`** — delete the message identified by `channel` + `event` (the `<id>` from
`buzz_read`). Owner/admin only (relay-enforced); the shim resolves the target within the named
channel and refuses a cross-channel target.
## Connector reliability (v0.2.8)
Long-lived sessions are kept healthy automatically:
- **Wire token keep-alive.** The wire ("post as me") access token is refreshed ahead of expiry
(~75% of its life) and all refreshes are **single-flight** — one in flight at a time. Ekam's
refresh tokens are single-use/rotating, so a *concurrent* double-refresh would trip reuse
detection and revoke the token family; single-flight + rotate-and-persist prevents that, so a
session renews itself indefinitely and re-login is reserved for a genuine revoke.
- **Transport self-heal.** If Node's built-in fetch pool wedges on a long session (every read/post
throwing while the connection looks up), the shim retries over a fresh socket and reroutes
subsequent calls, so it recovers **without a restart**. `buzz_whoami` shows a `transport:` line;
if it ever reports wedged, a full client restart (not just reconnect) clears it.
## Direct messages
Four DM tools, working as you in wire mode (and as the agent in local mode):
- **`buzz_dm_list`** — your DM conversations (other participant + channel id).
- **`buzz_dm_read`** — read a DM, by `to` (the other person) or `channel` (dm id).
- **`buzz_dm_open`** — open (or find) a 1:1 and return its channel id.
- **`buzz_dm_send`** — send a DM; opens the 1:1 first if needed.
Address a person with `to` = **npub / hex pubkey / exact display-name**, or their **email**.
- **By name**: resolved via the relay's `POST /profiles/search` (falling back to NIP-50 search on
relays without it) and matched **only** against the profile's `name` and `display_name` — a hit
that came back because of its bio / about / nip05 never resolves. Both sides are normalized
(NFKC + case-fold, Bidi_Control and default-ignorable characters stripped) and compared by their
Unicode TR39 **skeleton** (vendored `confusables.mjs`), so a Cyrillic / fullwidth / ligature /
zero-width lookalike collides with the real name. Resolution is **fail-closed**: only an exact
(NFC) match on exactly one person resolves — and, since v0.2.32-pre, a single match that differs
only in letter case (`navendu` → `Navendu`; `CASE_ONLY_IS_AMBIGUOUS`, default `false`). Two or more
matches (e.g. `Sam` and `SAM`), or a single match that is only a lookalike / confusable or carries
hidden bidi / invisible characters, is refused with "use an npub". Identities on the relay's NIP-IA archived list (kind:13535 signed by the
relay's NIP-11 `self` key) are never resolved and are hidden from `buzz_agents`.
- **By email**: **wire mode only**, via Ekam's directory to the person's canonical live pubkey. In
agent mode email is **refused** (use an npub or display name): a profile's `nip05` is self-asserted,
so trusting it would let anyone claim a colleague's address and receive their DMs/moderation.
An email containing non-ASCII, invisible/bidi characters, or anything NFKC would change is
refused **before** any Ekam / directory call.
DMs are membership-gated plaintext (kind:9) — the shim never encrypts/decrypts; the relay enforces who can read.
## Hosted mode (`buzz-mcp --http`) — the "Ola Buzz" connector (v0.2.35, Phase 1)
One server, many people: MCP **Streamable HTTP** at `https://mcp.buzz.ola.in/mcp` (stateful
`Mcp-Session-Id` sessions). stdio mode is unchanged; `--http` (or `BUZZ_MCP_HTTP=1`) switches.
**Auth (OAuth 2.1 resource server, MCP authorization 2025-06-18).**
- `GET /.well-known/oauth-protected-resource` (RFC 9728): `resource` = `BUZZ_HOSTED_RESOURCE`
(default `https://mcp.buzz.ola.in`), `authorization_servers` = [Ekam].
- No/invalid token → `401` + `WWW-Authenticate: Bearer resource_metadata="…"` (`error="invalid_token"`).
- Bearer validated for **audience = the resource**, issuer = Ekam, `exp`/`nbf` (60 s skew), `sub` —
via Ekam JWKS (asymmetric algs only; `none`/HS refused) or RFC 7662 introspection
(`BUZZ_HOSTED_TOKEN_VALIDATION=jwks|introspection|auto`).
- The client's token **never** goes to the relay or to wire-sign. It is RFC 8693-exchanged at Ekam's
token endpoint for a short-lived **`wire:sign`** token for the same person (`resource` = Ekam issuer),
cached in memory per user until 60 s before expiry. Optional encrypted-at-rest cache:
`BUZZ_HOSTED_TOKEN_STORE=<path>` + `BUZZ_HOSTED_TOKEN_STORE_KEY=<base64 32 bytes>` (AES-256-GCM,
0600; refuses to start without the key — never plaintext).
- The person's pubkey is learned from a throwaway signed NIP-98 probe — `/v1/me/wire-key` is never called.
**Two Ekam clients (sign-in vs exchange).** Ekam registers two OAuth clients for this connector:
| client id | type | who uses it | for |
|---|---|---|---|
| `buzz-mcp-hosted` | **public**, PKCE `S256`, no secret | the MCP client (Grok Bot) | signing the person in; its access token has audience **`https://mcp.buzz.ola.in`** and is the bearer sent to `/mcp` |
| `buzz-mcp-hosted-exchange` | **confidential** (client secret) | this server only | the RFC 8693 token exchange at Ekam's token endpoint |
- `BUZZ_HOSTED_CLIENT_ID` / `BUZZ_HOSTED_CLIENT_SECRET` hold the **exchange** client's credentials
(`buzz-mcp-hosted-exchange`), never the sign-in client's. The secret is sent as HTTP Basic
(`client_id:secret`, RFC 6749 §2.3.1) on every exchange.
- The exchange is RFC 8693 (`grant_type=urn:ietf:params:oauth:grant-type:token-exchange`,
`subject_token` = the person's validated access token, `scope=wire:sign`, `resource` = Ekam issuer). Ekam
returns a **300 s** `wire:sign` access token and **no refresh token** (any refresh/id token in the
response is dropped); the server re-exchanges from the client's token when it nears expiry.
- `wire:sign` is **not** advertised in `/.well-known/oauth-protected-resource` (`scopes_supported` stays
`openid profile email`): clients never request it; only the exchange client can obtain it.
- **The secret is required (v0.2.36+).** With `BUZZ_HOSTED_CLIENT_SECRET` unset, empty or whitespace, or with
`BUZZ_HOSTED_CLIENT_ID` set to the public sign-in client, `buzz-mcp --http` refuses to start (exit 78,
one stderr line naming the variable; the value is never printed).
- A `401 invalid_client` (or `400 invalid_client`) from the exchange — wrong, rotated or missing secret, or
the wrong client id — is reported as an operator error: the tool call returns *"Ola Buzz server
misconfiguration: Ekam rejected the token-exchange client credentials … Reconnecting will not help."*
and an `exchange_client_rejected` JSON log event is written. Neither carries the secret, the Basic header
or Ekam's error description.
**Admin steps (Ekam + relay_sre, once per environment).**
1. Ekam admin: after Ekam's deploy, issue the client secret for `buzz-mcp-hosted-exchange`. It is shown
**once** — copy it straight into the secret store, not into chat, tickets or a ConfigMap.
2. relay_sre: store it in the Kubernetes Secret `buzz-mcp-hosted` as `BUZZ_HOSTED_CLIENT_SECRET`, with
`BUZZ_HOSTED_CLIENT_ID=buzz-mcp-hosted-exchange` (see `deploy/k8s/buzz-mcp-hosted.yaml`). The Deployment
references the Secret as required, so a pod without it does not start.
3. Ekam admin: set the Grok Bot redirect URI(s) on the **sign-in** client `buzz-mcp-hosted` (they are empty
until set, so sign-in fails until then). Use the callback URL(s) Grok Bot publishes for remote MCP
connectors; do not guess them.
4. Rotation: issue a new secret, update the Secret, restart the pods. Until they restart, tool calls get the
`invalid_client` operator error above.
**Isolation.** Each session has its own core (identity, channel/profile/relay-info caches, confirm gate);
a session id presented with another person's token → `403`. Write guard and rate limit are per user.
Hosted cores see an allowlisted env only — server `BUZZ_PRIVATE_KEY` / `BUZZ_AUTH_TAG` are ignored.
**Hosted differences.** 54 tools (no `buzz_login`); no local file paths (`attachment` removed;
`buzz_attachment_read` returns text inline / describes binaries, nothing is saved); no
`CLAUDE_CONFIG_DIR` isolation check; no profile publish. `buzz_whoami` → `Ola Buzz — posting as you
(<name>)` + shim version; write results end `— posted as you (<name>)`. Not provisioned in Buzz →
"Open the Buzz app once and sign in with Ekam, then try again."
**Ops.** `GET /healthz`; JSON logs on stdout (hashed user, session prefix, tool, status, ms — never
tokens); `413` over `BUZZ_HOSTED_MAX_BODY_KB` (1024); `429` + `Retry-After` over
`BUZZ_HOSTED_RATE_PER_MIN` (120/user); browser `Origin` refused unless in `BUZZ_HOSTED_ALLOWED_ORIGINS`;
`BUZZ_HOSTED_ALLOWED_HOSTS` (Host check); session caps (`BUZZ_HOSTED_MAX_SESSIONS_PER_USER` 8,
`BUZZ_HOSTED_MAX_SESSIONS` 200, idle `BUZZ_HOSTED_SESSION_IDLE_S` 1800). Sessions are in memory —
use session affinity (`deploy/k8s/buzz-mcp-hosted.yaml`). `Dockerfile`: base pinned by digest, runs as uid 1000,
no install/build step — it copies the prebuilt, checksum-verified `dist/buzz-mcp.mjs` only. Resource
numbers: `node bench/hosted-load.mjs` (see the v0.2.35 PR).
**Pre-auth rate limit and client address.** A request with no bearer always gets the `401` +
`WWW-Authenticate` challenge and is never rate limited (it costs nothing upstream). A bearer this pod has
not already validated is charged to a per-client-address bucket (`BUZZ_HOSTED_ANON_RATE_PER_MIN`, 60/min)
**before** it is validated, so junk tokens cannot turn into unbounded introspection / JWKS calls at Ekam;
a bearer that was already accepted (cached ≤ 60 s and never past its `exp`) skips that bucket and goes
straight to the per-user limit. Every `429` on `/mcp` keeps `Retry-After` **and** the
`WWW-Authenticate: Bearer resource_metadata=…` challenge, so a throttled client can still re-authenticate.
The client address is the socket peer by default — behind an ingress that is the ingress pod, so all
clients share one bucket. To key on the real client, opt in with `BUZZ_HOSTED_TRUSTED_PROXY_HOPS=N`: the
address is then the **N-th `X-Forwarded-For` entry from the right**, where N is the number of proxies you
operate that each append the address they saw (one ingress-nginx in front → `1`; a cloud L7 LB in front of
ingress-nginx → `2`). Entries further left are client-supplied and never used. Optionally also set
`BUZZ_HOSTED_TRUSTED_PROXY_CIDRS` (comma list, e.g. the ingress pod CIDR) so `X-Forwarded-For` is only
read when the socket peer is one of your proxies. With `HOPS` unset or `0`, `X-Forwarded-For` is never read;
a missing header, fewer entries than `HOPS`, or a non-IP at that position falls back to the socket peer.
Bad values (non-integer / >10 hops, malformed CIDR, CIDRs without hops) refuse to start.
**Token checks.** JWT access tokens must carry `iss` equal to the issuer (missing `iss` → `401
invalid_token`); introspection responses may omit `iss` (optional in RFC 7662) but a present one must match.
The person's pubkey (learned from a wire-sign probe) is cached for `BUZZ_HOSTED_PUBKEY_TTL_S` (3600 s)
and re-probed for the next new session after that. The optional encrypted token store writes a randomly
named temp file opened `O_CREAT|O_EXCL` (`wx`), then renames it over the store.
**Port / health.** Listens on `PORT` (default **8080**, also `BUZZ_HOSTED_PORT`), host `BUZZ_HOSTED_HOST`
(`0.0.0.0`). Health: **`GET /healthz`** → `200 {"ok":true,"name":"Ola Buzz","version":…,"sessions":…}` (no auth). MCP: `/mcp`.
Metadata: `/.well-known/oauth-protected-resource` (and `…/mcp`).
**Secrets** (mount from a Secret / KMS; never in a ConfigMap or the image):
| secret env | required | |
|---|---|---|
| `BUZZ_HOSTED_CLIENT_ID` | **yes** — `buzz-mcp-hosted-exchange` (not secret, but Ekam-issued; placeholder `REPLACE_WITH_EKAM_CLIENT_ID`; the public sign-in client `buzz-mcp-hosted` is refused) | exchange client id |
| `BUZZ_HOSTED_CLIENT_SECRET` | **yes** — the server refuses to start without it (v0.2.36+; v0.2.35 treats it as optional) | exchange client secret (issued once by an Ekam admin), HTTP Basic at the token endpoint |
| `BUZZ_HOSTED_TOKEN_STORE_KEY` | only with `BUZZ_HOSTED_TOKEN_STORE` | base64 32-byte AES-256-GCM key |
No other secret is read. `BUZZ_PRIVATE_KEY` / `BUZZ_AUTH_TAG` are **ignored** in hosted mode — do not set them.
**All environment variables (hosted mode):**
| env | default | meaning |
|---|---|---|
| `BUZZ_MCP_HTTP` | — | `1` = same as `--http` |
| `PORT` / `BUZZ_HOSTED_PORT` | `8080` | listen port |
| `BUZZ_HOSTED_HOST` | `0.0.0.0` | listen address |
| `BUZZ_HOSTED_RESOURCE` | `https://mcp.buzz.ola.in` | resource id = required token audience |
| `BUZZ_HOSTED_PUBLIC_URL` | = resource | public base URL used in metadata / `WWW-Authenticate` |
| `BUZZ_HOSTED_MCP_PATH` | `/mcp` | MCP endpoint path |
| `BUZZ_HOSTED_ISSUER` | `BUZZ_EKAM_BASE` | expected `iss` / AS |
| `BUZZ_EKAM_BASE` | `https://ekam.olakrutrim.com` | Ekam base (AS metadata, wire-sign) |
| `BUZZ_HOSTED_TOKEN_VALIDATION` | `auto` | `auto` (JWKS, else introspection) \| `jwks` \| `introspection` |
| `BUZZ_HOSTED_JWKS_URI` | from AS metadata | override |
| `BUZZ_HOSTED_INTROSPECTION_ENDPOINT` | from AS metadata | override |
| `BUZZ_HOSTED_TOKEN_ENDPOINT` | from AS metadata | override |
| `BUZZ_HOSTED_METADATA_TTL_S` | `3600` | AS metadata / JWKS cache |
| `BUZZ_HOSTED_CLOCK_SKEW_S` | `60` | `exp`/`nbf` leeway (max 300) |
| `BUZZ_HOSTED_EXCHANGE_FORMAT` | `json` | `form` = urlencoded RFC 6749 body |
| `BUZZ_HOSTED_EXCHANGE_SCOPE` | `wire:sign` | scope requested in exchange |
| `BUZZ_HOSTED_EXCHANGE_RESOURCE` | = issuer | `resource` for the exchanged token |
| `BUZZ_HOSTED_REQUIRED_SCOPES` | — | scopes the client token must carry (else 403 `insufficient_scope`) |
| `BUZZ_HOSTED_SCOPES_SUPPORTED` | `openid,profile,email` | advertised in PRM |
| `BUZZ_HOSTED_TOKEN_STORE` | — (memory only) | path of the encrypted wire:sign cache |
| `BUZZ_HOSTED_ALLOWED_ORIGINS` | — (no browser origins) | comma list of allowed `Origin`s |
| `BUZZ_HOSTED_ALLOWED_HOSTS` | — (any) | comma list of allowed `Host`s (else 421) |
| `BUZZ_HOSTED_MAX_BODY_KB` | `1024` | request body cap (413) |
| `BUZZ_HOSTED_RATE_PER_MIN` | `120` | per-user requests/min (429) |
| `BUZZ_HOSTED_ANON_RATE_PER_MIN` | `60` | per-client-address requests/min for bearers not yet validated (charged before validation; no-token 401s are not limited) |
| `BUZZ_HOSTED_TRUSTED_PROXY_HOPS` | `0` (off) | `N` ≥ 1: client address = N-th `X-Forwarded-For` entry from the right (max 10); `0` = socket peer, XFF never read |
| `BUZZ_HOSTED_TRUSTED_PROXY_CIDRS` | — (any peer, when HOPS ≥ 1) | comma list of proxy CIDRs; XFF only read when the socket peer is inside one (requires HOPS ≥ 1) |
| `BUZZ_HOSTED_PUBKEY_TTL_S` | `3600` (60–86400) | how long a person's probed pubkey is reused for new sessions |
| `BUZZ_HOSTED_MAX_SESSIONS_PER_USER` | `8` | oldest evicted beyond this |
| `BUZZ_HOSTED_MAX_SESSIONS` | `200` | per pod; 503 beyond (≈3.5 MiB RSS per active session — size the memory limit to match) |
| `BUZZ_HOSTED_SESSION_IDLE_S` | `1800` | idle session expiry |
| `BUZZ_HOSTED_JSON_RESPONSE` | — | `1` = JSON responses instead of SSE |
| `BUZZ_HOSTED_ALLOW_PLACEHOLDER` | — | `1` silences the placeholder-client-id warning (tests) |
| `BUZZ_HOSTED_ATTACHMENT_MAX_MB` | `5` (max 50) | in-memory `buzz_attachment_read` cap |
| `BUZZ_RELAY_HTTP` | `http://localhost:3000` | relay HTTP bridge (**set it**) |
| `BUZZ_SIGN_BASE` | = relay | NIP-98 `u` base |
| `BUZZ_TZ` | `UTC` | display zone (Ola: `Asia/Kolkata`) |
| `BUZZ_HTTP_TIMEOUT_S` / `BUZZ_RELAYINFO_TIMEOUT_S` / `BUZZ_TRANSFER_TIMEOUT_S` | `25` / `8` / `600` | relay timeouts |
| `BUZZ_ONBOARDING_GUIDE` | — | onboarding guide path |
Per-session cores see only `BUZZ_RELAY_HTTP`, `BUZZ_SIGN_BASE`, `BUZZ_EKAM_BASE`, `BUZZ_TZ`, the three timeouts,
`BUZZ_HOSTED_ATTACHMENT_MAX_MB` and `BUZZ_ONBOARDING_GUIDE`; the rest of the process env is invisible to them.
## Security
- No macOS `security` CLI / no secret on process argv (SEC-1 #27, cleared).
- No client-side owner-seed derivation (Ekam PR #286): the shim **re-fetches** its
derived key, never re-derives from a seed.
- The Phase-2 wire-key stays in memory; only the rotatable service-refresh persists
(0600), never the raw nsec.
- Wire-sign mode holds **no key at all** — only a rotating, revocable `wire:sign` refresh
(0600); Ekam signs server-side against a `resource=ISSUER` + `wire:sign`-scoped token,
and the gate rejects any other kind. Treat the refresh like a credential (0600, never
log/commit); revoking it in Ekam kills the shim's ability to act as you.
## Reads on the bus (governed agents)
`bridge()` sends the owner delegation as the `x-auth-tag` header when `BUZZ_AUTH_TAG`
is set — required or a ViaOwner agent 403s on `/query`.
## BUZZ_SERVICE_REFRESH is a CREDENTIAL
Treat `BUZZ_SERVICE_REFRESH` (and `BUZZ_PRIVATE_KEY`) as a secret: store the config 0600, never log it, never paste it, never commit it. The shim exchanges it (service-refresh → /oauth/token → /v1/me/wire-key) and holds the key in memory only.
## Samples
`samples/` has paste-ready MCP config blocks for both routes (`mcp-config-human.jsonc`,
`mcp-config-agent.jsonc`), an `env.example`, and `example-client.mjs` — a minimal
MCP-stdio client (whoami → read → post → DM, read-only until you flip the write flags)
that spawns the installed `buzz-mcp` command, so you can verify the connection from clean.
## Building the bundle (maintainers / review)
The runtime is `dist/buzz-mcp.mjs` + `dist/wirelogin.mjs` — self-contained bundles built
from the readable source in this repo, so the *install* needs no `npm install`. To
regenerate (and verify they match what ships):
```sh
npm ci # install pinned deps from package-lock.json into a REAL node_modules
# (not a symlink — a symlinked node_modules bakes absolute paths into
# the bundle comments)
npm run build # esbuild → dist/buzz-mcp.mjs + dist/wirelogin.mjs (esbuild is a pinned devDep)
npm test # all suites, serially (~1,800 assertions)
sha256sum -c dist/SHA256SUMS # CI (.github/workflows/ci.yml) enforces this after a rebuild
```
`dist/` is `esbuild(<reviewed source> + deps pinned by package-lock.json)` — review the
source; the bundle is derived. Both bundles carry a `#!/usr/bin/env node` shebang so the
`bin` entries are directly executable. (Tests live in the repo for `npm test`; they are not
in the published `files[]` — the end-user install stays no-npm and runs only the bundle.)
## Changelog
### v0.2.37
- Version bump to 0.2.37. Ships #31 (refresh single-flight) plus the follow-ups below.
- **buzz_security must-fixes on #31:**
- **Stale-lock break is serialized.** Two processes breaking the same stale `<id>.lock` could remove
each other's new lock, so two holders POSTed the same refresh (a replay → Ekam revokes the family).
A breaker now takes an exclusive `<id>.lock.break` (own 5s stale time), re-reads the lock inside it
and removes it only if its nonce is still the stale one. Right before the refresh POST the holder
re-checks that it still owns the lock and that `.tok` still holds the refresh it is sending;
otherwise it sends nothing and goes round again.
- **`invalid_grant` retries: default 1 attempt (no retry), cap 3** (`BUZZ_WIRE_INVALID_GRANT_ATTEMPTS`,
was default 3, max 10). An `error_description` with "replay" / "family revoked" is final at once,
whatever the setting.
- **Wire mode: rotating-refresh races fixed** (#31). Cross-process `O_EXCL` lock + in-process single-flight
around the Ekam refresh, re-read and adopt under the lock, atomic 0600 writes. Previously each MCP
session's shim read the refresh once at startup, so a second session replayed a spent token
(`invalid_grant`, Ekam family revocation for `refresh_token_replay`) and kept POSTing it on every
signed request.
- `invalid_grant` / `invalid_client`: bounded retries (`BUZZ_WIRE_INVALID_GRANT_ATTEMPTS`, default 1, cap 3
since the must-fix above), then the stored refresh is cleared and the error asks you to sign in (`code: WIRE_SIGNIN_REQUIRED`).
Transient errors back off (`code: WIRE_REFRESH_BACKOFF`) with a persistent window.
- Tests: new `refresh.test.mjs` (mocked token endpoint, real child processes). `bridge.test.mjs` no
longer sends a refresh to prod Ekam during `npm test`.
- **Wire keep-alive starts at process start** (Ekam 7-day sliding refresh idle). With a stored `.tok`,
the stdio shim refreshes once after a jittered 3–60s delay, through the same lock and single-flight,
then the normal ~75% renew takes over. No token means nothing happens; failures fall back to on-demand.
Off in hosted mode. Opt out with `BUZZ_WIRE_STARTUP_KEEPALIVE=0`.
- **Ekam calls send the shim version in `User-Agent`** (previously node's bare default): refresh/mint,
login code exchange, wire-sign, wire-key and directory calls send
`buzz-mcp/<version> (node/<node version>; <platform>)`; the hosted server's metadata, JWKS,
introspection and token-exchange calls send `buzz-mcp/<version> hosted (...)`. No user, host, client
or token identifiers. The version comes from `SHIM_VERSION` in `clientversion.mjs`, now the single
source that `buzz-mcp.mjs` re-exports. Relay calls are unchanged (they already send `x-buzz-client`).
### v0.2.36
- Version bump to 0.2.36. The changes below are already on `main` (#27, #22, #28); this release tags them.
- Hosted secret startup check (#27). Ekam #481 two-client model: `BUZZ_HOSTED_CLIENT_ID` / `BUZZ_HOSTED_CLIENT_SECRET` are the confidential
exchange client `buzz-mcp-hosted-exchange`; the secret is now **required** (hosted mode refuses to start
without it, exit 78, value never printed), the public sign-in client id is refused as the exchange client,
and a `401 invalid_client` from the exchange is a clear operator error (`exchange_client_rejected`) with no
secret or Ekam description in it. Sample k8s references a required Secret. Tests: `hosted.test.mjs` case 16.
- v0.2.35 hosted mode sends the secret if set but does not require it; v0.2.36 refuses to start without it.
- **Attachment hardening** (buzz_security attachment ruling, #22; three review rounds):
- **Attachment type allow-list, from the bytes.** `signer.mjs classifyAttachment` replaces the
extension→MIME map: images jpeg/png/gif/webp, mp4, and pdf/docx/xlsx/pptx/odt/ods/odp/txt/csv/md/json.
HTML/SVG/XML, zip and other archives, OLE, OOXML with `vbaProject.bin`, ODF with Basic/Scripts,
and executables are refused before any upload. Text must be UTF-8, no controls, no markup.
- **Names.** Sanitised (NFC; Cf/bidi, C0/C1, `/` `\` stripped; dots/spaces trimmed; ≤120 bytes),
extension forced to the sniffed type; non-media files post as `[escaped name](url)`.
- **Folders** classify every file first (no partial post); disallowed types are skipped and listed.
- `@hono/node-server` 2.1.5 (clears GHSA-rmxm-3fg6-px4f in the unused `serveStatic`; `npm audit` 0).
- **Licence: Apache-2.0** (#28): `LICENSE` added and `package.json` `license` set to `Apache-2.0`.
### v0.2.35
- **Hosted mode (`buzz-mcp --http`)** for the "Ola Buzz" Grok Bot connector, Phase 1 — see *Hosted mode*.
Streamable HTTP, OAuth resource server (Ekam), RFC 8693 exchange to `wire:sign`, per-session
isolation, hosted disables, `/healthz`, rate/body limits, Dockerfile + sample k8s. stdio unchanged.
- `createBuzzServer(opts)` factory (stdio calls it once with `process.env`). `hosted.test.mjs` (mocked Ekam + relay).
- Token hygiene: exact-value scrub of the session's bearer / wire:sign token in every outgoing text, no raw Ekam
error bodies in hosted mode, refresh/id tokens from the exchange dropped (tested: `hosted.test.mjs` case 9).
- Dockerfile base pinned by digest, uid 1000, no install/build step. `bench/hosted-load.mjs`; default
`BUZZ_HOSTED_MAX_SESSIONS` 200 per pod (≈3.5 MiB per active session measured).
- Review fixes (buzz_security): the pre-auth per-address limit is charged **before** token validation (and
skipped for already-validated bearers); no-token `401` challenges are never limited; every `429` keeps
`WWW-Authenticate`; opt-in `BUZZ_HOSTED_TRUSTED_PROXY_HOPS` / `BUZZ_HOSTED_TRUSTED_PROXY_CIDRS` key the
limit on the real client from `X-Forwarded-For`; JWTs must carry `iss`; pubkey cache TTL
(`BUZZ_HOSTED_PUBKEY_TTL_S`); token-store temp file opened `O_EXCL`. Tests: `hosted.test.mjs` cases 10–15.
### v0.2.34
- **Canvases, reminders, workflows (55 tools).** `buzz_canvas_read`, `buzz_canvas_write`, `buzz_remind`,
`buzz_reminders`, `buzz_reminder_done`, `buzz_workflows`, `buzz_workflow_run`. See the parity table.
- **Canvas (kind 40100).** Read shows the newest signature-verified canvas as untrusted, fenced text
plus its `expected_head`. Write is read-before-write: `expected_head` is required (`none` for a first
canvas, a full 64-hex id otherwise) and the head is re-queried just before signing; if it moved, the
write is refused ("re-read"). ⚠ This is a **head check, not compare-and-swap** — two writers racing
inside the same instant can still both land. TODO(buzz_dx): switch to upstream's after-head CAS once
ported. ≤ 131072 bytes; empty content needs `clear: true`; `created_at` is kept above the head's.
- **Reminders (kind 30300).** NIP-44 encrypted to yourself (the v0.2.33 `nip44.mjs`), `not_before` for
due time, 128-bit random `d`. Target-message reminders use the desktop shape (`eventId`, `channelId`,
`preview`, `authorPubkey`) plus the NIP-ER `id`. Only your own, signature-verified events are read
(verification happens *before* picking the newest per `d`, so a forged newer one can't hide the real
one). Done before due is refused (use `cancel: true`); done / cancelled reminders carry an
`expiration` and no `not_before`. Agent mode only.
- **Workflows (kinds 30620 / 46020).** Definitions are untrusted: names escaped, YAML fenced line by
line and capped. `buzz_workflow_run` resolves your **own** workflows first and refuses someone
else's or a disabled one. **Approvals fail closed:** there is no approval tool, and the signer refuses
kinds 46030 / 46031 (`NEVER_SIGN`) in every mode, including dry run — so the agent can never approve
a step it (or anyone) triggered. A workflow with an approval step needs `confirm: true` to trigger and
then waits for a human.
- **Wire mode:** no new kinds on `WIRE_ALLOWLIST`. Canvas write and workflow run are refused clearly
(TODO: pending relay_sre / releng on which kinds the backend accepts); reminders are agent-mode only
(no encrypt-to-self at Ekam). Canvas read and workflow list work in both modes.
- `validateArgs` accepts `object`-typed arguments (workflow `inputs`: flat, ≤ 32 keys, string / number /
boolean values, each ≤ 4096 chars, ≤ 16 KB total).
- New write tools get `dry_run` automatically.
### v0.2.33
- **Slack parity batch 2 (48 tools).** `buzz_profile_set`, `buzz_mark_read`, `buzz_save`, `buzz_saved`,
`buzz_post_diff`; `buzz_unread` now gives real counts. See the parity table above.
- **NIP-44 v2, vendored (`nip44.mjs`).** Spec-strict (1–65535-byte plaintext; the non-spec
>64 KiB extension in nostr-tools is refused), constant-time MAC check before decrypt, strict
base64, message keys zeroed. Built only on `@noble/*` already in the lockfile (now pinned as exact
devDependencies — no new packages). Tested against the official NIP-44 vectors
(`vectors/nip44.vectors.json`, sha256-pinned) and cross-checked against nostr-tools. The local
signer exposes encrypt/decrypt/derive **to self only** (the conversation key never leaves the
closure and is zeroed on wipe); the wire signer has none.
- **Real unread + mark read (NIP-RS, kind 30078).** Agent mode only. Read-state blobs are
NIP-44 encrypted to yourself; frontiers are message `created_at` (never the wall clock), forward
only, max-merged across all your clients. The shim's slot and `client_id` are derived from your key
(no state file); a different `client_id` at that slot is a conflict → refuses to publish. Only
your own, signature-verified blobs are read. Wire mode: `buzz_mark_read` refuses (no encrypt-to-self
at Ekam; 30078 is **not** added to the wire allowlist); `buzz_unread` keeps the heuristic, labelled.
- **`buzz_profile_set`** (kind 0: name / about / picture) with patch semantics: the newest own,
signature-verified kind 0 is merged, so a picture or hand-written bio also survives the boot
republish. `buzz_setname` stays as a deprecated alias. Refused in wire mode until Ekam allows kind 0.
- **Saved items (NIP-51 kind 10003).** Private by default (NIP-44 to self). If your existing list has
private content this shim can't decrypt (e.g. legacy NIP-04), it refuses to rewrite it.
- **`buzz_post_diff` (kind 40008)** — tags mirror the relay validator / buzz-sdk builder (`repo`,
`commit`, `file`, `parent-commit`, `branch`, `pr`, `l`, `description`, `truncated`); ≤ 61440 bytes;
thread replies use root/reply markers.
- **Status in wire mode:** clear refusal (30315 isn't on Ekam's allowlist) + TODO; unchanged in agent mode.
- **Deferred:** self-delete as author (needs relay confirmation that an author-signed 9005 is
accepted).
- New write tools get `dry_run` automatically.
### v0.2.32
- **Slack parity tools (43 total).** `buzz_message_link`, `buzz_activity`, `buzz_files`,
`buzz_presence`, `buzz_emoji` (all read-only), plus `buzz_channels scope:"open"` and
`buzz_search` filters `from` / `after` / `before` / `in`. See the parity table above.
- **`dry_run` on every write tool.** Intercepted at the signer: in a dry run only NIP-98 (kind
27235) read auth is signed; any other event stops before signing and is previewed (signatures and
`auth` redacted). Belt and braces: the bridge refuses `/events` and Blossom refuses uploads in a
dry run, the confirm gate never records, and `buzz_setname` doesn't change the session name.
- **Fail-closed throughout.** `from` / presence `users` resolve with the same ambiguity rules as
mentions; message links are parsed strictly (`buzz` scheme, `message` host, UUID channel,
64-hex ids, no extra/duplicate params) and the event must carry the link's `h` tag; presence counts
only events signed by the relay's NIP-11 `self` key; emoji URLs must be http(s), no credentials,
and are never fetched; every echoed string is escaped.
- **Reactions escape the emoji** they echo back.
### v0.2.32-pre
- **"Post as me" edit and leave.** Kinds 40003 (`buzz_edit_message`) and 9022 (`buzz_leave_channel`)
join the wire-sign allowlist, so in wire mode they now go to Ekam's `POST /v1/me/wire-sign`. They
only succeed once Ekam ships the matching allowlist change (pending, security-reviewed Ekam PR).
9008 / 5 / 41012 stay refused locally — Ekam refuses them by design — with a message saying so.
- **Clear Ekam refusals.** A `400 kind_not_allowed` from wire-sign becomes "Ekam (your signing
service) doesn't sign kind N yet; it currently allows {…}. Ask the Ekam owner to add it, or use an
agent's own key." The allowed set is parsed defensively from Ekam's `error_description`; no
version pinning.
- **Case-only names resolve.** `CASE_ONLY_IS_AMBIGUOUS` is now `false`: a single match that differs
only in letter case (e.g. `navendu` → `Navendu`) resolves. Lookalike / confusable matches, hidden
bidi / invisible characters and multiple matches (e.g. `Sam` and `SAM`) are still refused.
### v0.2.31
- **New tools (Slack parity, 38 total).** `buzz_edit_message` (kind 40003, your own messages; reads
show the newest edit by the same author, marked `(edited)`), `buzz_thread_read`,
`buzz_channel_topic` (topic / purpose, kind 9002), `buzz_leave_channel` (9022),
`buzz_archive_channel` (9002 `archived`), `buzz_delete_channel` (9008, always `confirm: true`),
`buzz_profile` (by npub / hex / email / exact name), `buzz_unreact` (kind 5, one `e` = your reaction),
`buzz_reactions`, `buzz_dm_add` (41011), `buzz_dm_hide` (41012). Group DMs: `buzz_dm_open` /
`buzz_dm_send` take `to` as a string or an array of up to 8.
In wire ("post as me") mode, kinds 40003 / 9022 / 9008 / 5 / 41012 are refused locally as
*blocked on Ekam wire-sign allowlist* until Ekam adds them. (v0.2.32-pre: 40003 / 9022 now go to Ekam.)
- **Attachments.** A file, or a folder (its top-level regular files, ≤10; hidden files are skipped). File *types* are a narrow allow-list since attach-hardening (see *Attachments*); in a folder, other types are skipped and listed.
A **denylist** (not an allowlist — any other file or folder is allowed) refuses known secret
locations, checked on both the typed path and the realpath, case-insensitively, so a symlink,
a symlinked parent folder or a hard link to a secret is refused too:
the shim's own key store and wire tokens (`~/.config/buzz-cli/**` incl. `identities/*.hex` and
`wire-refresh/*.tok`, plus the identity / token dirs the shim actually uses, resolved at runtime),
`~/.buzz`, `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.docker`, `~/.kube`, `~/.azure`, `~/.password-store`,
`~/.config/{gcloud,gh,ekam}`, keyrings and credential stores (`~/.local/share/keyrings`,
`~/.local/share/kwalletd`, `~/.kde/share/apps/kwallet`, `~/Library/Keychains`, `~/.pki`,
`AppData/…/Microsoft/{Credentials,Protect}` — also matched anywhere), and anywhere: `.netrc`,
`.git-credentials`, `.npmrc`, `.pypirc`, `.pgpass`, `.credentials.json`, `kubeconfig`, `id_*`,
`.env*`, `*.key`, `*.pem`, `*.hex`, `*.tok`, `*.authtag`, `*.keychain(-db)`, `*.keyring`, `*.kwl`,
and shell / REPL history (`.bash_history`, `.zsh_history`, `.sh_history`, `fish_history`,
`.histfile`, `.python_history`, `.node_repl_history`, `.psql_history`, `.mysql_history`, …,
PowerShell `ConsoleHost_history.txt`, `~/.zsh_sessions`).
Kernel pseudo-filesystems are never attachable: anything under `/proc` (e.g. `/proc/self/environ`,
`/proc/<pid>/environ`, `/proc/self/fd/N`), `/sys` or `/dev` is refused — on the typed path, the
realpath and every hop of a symlink chain (so a link that detours through `/proc/self/cwd` is
refused too); on Windows, `\\.\` / `\\?\GLOBALROOT` device paths. Hard links are checked
against every file in the secret directories at any depth (e.g.
`~/.config/gcloud/legacy_credentials/<acct>/adc.json`); a hard-linked file is refused if that walk
can't be completed.
The upload re-opens the checked file with `O_NOFOLLOW` and re-checks its device/inode, so a swap
after the check is refused. This is a best-effort denylist: a secret stored under an ordinary
name elsewhere is **not** detected. The result shows the resolved path.
- **Downloads.** `buzz_attachment_read` streams to a private 0700 dir, writing 0600 files opened with
`O_EXCL|O_NOFOLLOW`. It never overwrites and is size-capped (`BUZZ_DOWNLOAD_MAX_MB`, default 200).
- **Untrusted output.** Every tool that returns relay text (`buzz_read`, `buzz_dm_read`,
`buzz_thread_read`, `buzz_search`, `buzz_profile`, `buzz_attachment_read`, `buzz_channels`) starts
with an "untrusted data" note. Message text, names and channel descriptions are escaped: newlines,
C0/DEL, the C1 controls U+0080–U+009F (NEL included), and every invisible character — `\p{Bidi_Control}`, `\p{Default_Ignorable_Code_Point}`,
`\p{Cf}`, U+00AD, U+2061–2064 and the tag characters U+E0000–E007F — become visible `\u{..}`
escapes (one shared definition with `names.mjs`; a single emoji variation selector is kept). Authors are shown as `name (pk8)`, with `⚠name-collision`
when two people share a name, and the newest kind:0 wins. Inbound events are
signature-verified (messages, edits, reactions, profiles): bad ones are dropped by default (`BUZZ_VERIFY_INBOUND=flag` keeps and flags them),
and timestamps more than 5 minutes in the future are flagged.
Error text is untrusted too: the catch-all `error: …` result (and the join / edit-verify / login
error paths) is escaped the same way and capped at 1500 code points, so a relay error body or a
hostile profile / channel name can't inject a raw newline, control or bidi character. (Labels that
`names.mjs` already quoted show their backslashes doubled there, e.g. `\\u{202e}` — never raw.)
- **Write safety.** A per-session throttle (`BUZZ_WRITE_PER_MIN`, default 10, 0 = off) plus duplicate
suppression for an identical message or reaction within 2 minutes. @mentions use the
v0.2.30 fail-closed resolution: a name that two people share, or that only matches as a lookalike,
tags nobody and is reported. Channel names are resolved the same way (duplicates are refused with
the ids to pick from). In wire mode, the first 9001 / 9005 / 9002 / 41011 (DM add) of a session
needs `confirm: true`.
- **MCP hygiene.** Tool annotations (`readOnlyHint` / `destructiveHint` / `idempotentHint` /
`openWorldHint` / title), `additionalProperties: false`, and bounded `limit` / text arguments,
validated at call time. Client cancellation (`extra.signal`), hard timeouts
(`BUZZ_RELAYINFO_TIMEOUT_S`, `BUZZ_TRANSFER_TIMEOUT_S`) and progress notifications cover uploads and downloads.
- **Key hygiene.** Key env vars are removed from `process.env` after boot, child processes get a
minimal env, and the local signer's key buffer is zeroed on exit. The browser opens without
`cmd.exe` on Windows, and https only.
- **Repo.** `CODEOWNERS`, Dependabot (npm + actions), `SECURITY.md`, and a tag-triggered release
workflow (sha256 + `actions/attest-build-provenance`). `install.sh` now installs the latest release
tag by default and verifies sha256 before installing.
### v0.2.30
- **Lookalike-proof names.** Invisible-character handling uses Unicode properties
(`\p{Bidi_Control}`, `\p{Default_Ignorable_Code_Point}`, soft hyphen) instead of a literal
list — LRM / RLM / ALM, U+202A–202E, U+2066–2069, zero-widths, U+FEFF are now all covered.
Channel names refuse them on create and edit; variation selectors are stripped (so `Pulse 💓`
/ `Pulse ❤️` still work). Names compare by NFKC + case-fold + TR39 skeleton
(`names.mjs`, vendored `confusables.mjs` generated by `scripts/gen-confusables.mjs` from the
pinned Unicode 16.0.0 `confusables.txt`, SHA-256 recorded; still zero runtime deps).
- **Fail closed.** A single skeleton-only / case-only hit is refused ("ambiguous / lookalike - use
an npub"); `CASE_ONLY_IS_AMBIGUOUS` (default `true`; `false` since v0.2.32-pre) controls case-only matches. Non-ASCII,
invisible or NFKC-unstable emails are refused before any Ekam/directory call.
- **Relay-aware resolution.** `POST /profiles/search` with NIP-50 fallback; only `name` /
`display_name` ever match, exact first. NIP-IA archived identities (kind:13535 signed by the
relay's NIP-11 `self`; fail open if absent) are excluded from DM resolution and `buzz_agents`.
- **Client identification.** kind:10100 carries `["client","buzz-mcp",<version>]`; kind:0 carries
`client_version`.
- **Edit verification.** `buzz_edit_channel` re-reads kind:39000 and reports any mismatch
(private / name / ttl / description) — or an unreadable read-back — as an error.
- **p-tag guard.** Every signed event's `p` tags must be 64 lowercase hex; mention p-tags from
malformed profile pubkeys are dropped (and deduped).
- **@mentions fail closed** (fix after buzz_security's release review). `@name` is resolved with
the same exact → case → lookalike tiers as a recipient name, over the 200-newest profiles **plus**
a server-side profile search for each token. Two keys sharing a name (e.g. an old and a new
`buzz_security`), a lookalike-only match, or an archived identity → **nobody is tagged**, and the
post/reply result says which `@token` was not tagged and why. Typed `@tokens` match case-insensitively;
`Pulse 💓` → `@pulse` and `bharatrouter·falcon` → `@bharatrouterfalcon` still work.
- **Channel names fail closed.** A channel given by name resolves only when exactly one channel
matches (exact, or differing only in case). Two channels sharing a name (`general` / `General`) or a
lookalike-only match are refused with the channel ids to pick from — never the first match.
`buzz_join_channel` discovery uses the same rule.
- **Escaped labels; profile search fails closed** (buzz_security re-check). Every profile or
channel name, channel id and typed query echoed in an error (ambiguous / lookalike / not found,
including the `Available:` channel list, capped at 50 with "…and N more") is quoted with C0/C1,
bidi, zero-width, tag and other invisible characters shown as `\u{..}` escapes and capped at 64
code points. If `/profiles/search` fails (anything but 404/405) **and** the NIP-50 fallback also
fails, the name is not resolved from the partial 200-newest list: DM recipients get "profile
search for "x" failed … use an npub", and that `@mention` is not tagged (the post says why).
- `@modelcontextprotocol/sdk` → 1.32.1 (clears the SDK advisory); `npm audit fix` bumps the
transitive zod 4.5.4 → 4.6.7 and fast-uri 3.1.7 → 3.1.9 (both bundled into dist), plus
proxy-addr, body-parser, hono and ip-address (dev-only, not bundled): `npm audit` 6 → 0; new `names.test.mjs`,
`loadkey.test.mjs`; `bridge.test.mjs` capture cases now FAIL instead of skip when nothing was
captured; CI workflow with SHA-pinned actions and a reproducible-dist check (`dist/SHA256SUMS`).
TDQS
Scored across 55 tools
Most tools are clearly distinguished by resource plus action: channel, message, DM, reaction, reminder, canvas, and workflow operations have identifiable boundaries. A few overlaps remain, notably deprecated buzz_setname aliasing buzz_profile_set, and the related read/activity-state tools buzz_unread, buzz_activity, and buzz_mark_read, though their descriptions clarify the differences.
All names use snake_case with a consistent buzz_ prefix, so the family is recognizable. However, the set mixes verb-first names (buzz_create_channel, buzz_edit_message) with resource-first names (buzz_dm_send, buzz_channel_members, buzz_workflow_run) and bare nouns (buzz_channels, buzz_profile, buzz_saved), so there is no single predictable pattern.
At 55 tools, this is far beyond the well-scoped 3-15 range and creates an extremely large selection surface and context burden for an agent. Many operations are legitimate and granular, but the overall tool count is an extreme over-scope for a single MCP server.
Core collaboration lifecycle coverage is strong: identity, profiles, channels, messages, threads, DMs, reactions, attachments, search, reminders, read state, saved items, canvases, and workflows are all represented. Minor gaps remain, such as no workflow create/edit/delete operations, limited post-add role management, and no pin/unpin or reminder snooze/edit tools.