Nostr
Moltis can receive and send encrypted direct messages over
Nostr, the decentralized social protocol. The integration
uses NIP-04
encrypted DMs (kind:4) and connects to relays via nostr-sdk — no public URL
or server infrastructure is required. It can also join
NIP-29 group chats,
including Block’s Buzz channels — see
Buzz & NIP-29 Groups.
How It Works
┌──────────────────────────────────────────────────────┐
│ Nostr Relay Network │
│ (relay.damus.io, nos.lol, relay.nostr.band, ...) │
└──────────────────┬───────────────────────────────────┘
│ WebSocket subscription (kind:4)
▼
┌──────────────────────────────────────────────────────┐
│ moltis-nostr crate │
│ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ Bus │ │ Outbound │ │ Plugin │ │
│ │ (inbound) │ │ (replies) │ │ (lifecycle) │ │
│ └────────────┘ └────────────┘ └────────────────┘ │
└──────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Moltis Gateway │
│ (chat dispatch, tools, memory) │
└──────────────────────────────────────────────────────┘
The bot connects outward to Nostr relays via WebSocket. No port forwarding, public domain, or TLS certificate is needed. Messages are end-to-end encrypted between the sender and the bot using NIP-04.
Prerequisites
Before configuring Moltis, you need a Nostr secret key:
- Generate a new key pair using any Nostr client (e.g. Damus, Amethyst, or a key generation tool)
- Copy the secret key — either the
nsec1...bech32 format or the 64-character hex format - Note the corresponding public key (
npub1...) to share with users who want to message the bot
Configuration
Add a [channels.nostr.<account-id>] section to your moltis.toml:
[channels.nostr.my-bot]
secret_key = "nsec1..."
relays = ["wss://relay.damus.io", "wss://relay.nostr.band", "wss://nos.lol"]
dm_policy = "allowlist"
allowed_pubkeys = ["npub1abc...", "npub1def..."]
Make sure "nostr" is included in channels.offered (it is by default):
[channels]
offered = ["telegram", "discord", "slack", "matrix", "nostr"]
Configuration Fields
| Field | Required | Default | Description |
|---|---|---|---|
secret_key | yes | — | Nostr secret key (nsec1... bech32 or 64-char hex) |
relays | no | ["wss://relay.damus.io", "wss://relay.nostr.band", "wss://nos.lol"] | Relay WebSocket URLs to connect to |
dm_policy | no | "allowlist" | Who can DM the bot: "open", "allowlist", or "disabled" |
allowed_pubkeys | no | [] | Public keys allowed to DM (npub1... or hex, when dm_policy = "allowlist") |
enabled | no | true | Whether this account is active |
model | no | — | Override the default model for this channel |
model_provider | no | — | Provider for the overridden model |
otp_self_approval | no | true | Allow non-allowlisted senders to self-approve via OTP code |
otp_cooldown_secs | no | 300 | Cooldown after 3 failed OTP attempts |
groups | no | [] | NIP-29 group ids (h tags) to join — see Buzz & NIP-29 Groups. Empty = DM-only |
group_mention_mode | no | "mention" | When to respond in groups: "mention" (p-tagged only), "always", or "none" |
group_message_kind | no | "nip29" | Dialect for bot-initiated group messages: "nip29" (kind:9) or "buzz_v2" (kind:40002) |
group_ack_reactions | no | true | React 👀 on receipt and ✅/❌ on completion in groups (NIP-25) |
profile.name | no | — | NIP-01 profile display name |
profile.display_name | no | — | NIP-01 longer display name |
profile.about | no | — | NIP-01 bio / about text |
profile.picture | no | — | NIP-01 avatar URL (HTTPS) |
profile.nip05 | no | — | NIP-05 identifier (e.g. bot@example.com) |
Full Example
[channels.nostr.my-bot]
secret_key = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspcgef"
relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
]
dm_policy = "allowlist"
allowed_pubkeys = [
"npub1abc123...",
]
model = "anthropic/claude-sonnet-4-20250514"
model_provider = "anthropic"
otp_self_approval = true
otp_cooldown_secs = 300
[channels.nostr.my-bot.profile]
name = "Moltis Bot"
about = "AI assistant on Nostr"
nip05 = "bot@example.com"
Access Control
DM Policy
allowlist(default) — Only public keys inallowed_pubkeyscan message the bot. Unknown senders receive an OTP challenge ifotp_self_approvalis enabled, or are silently ignored.open— Anyone can DM the bot.disabled— All inbound DMs are ignored.
OTP Self-Approval
When otp_self_approval is enabled and a non-allowlisted sender messages the
bot, the sender appears in the Senders tab of the web UI where they can be
approved or denied. This works the same as OTP for Telegram and Matrix.
Buzz & NIP-29 Groups
Buzz is Block’s open-source team-chat and agent-collaboration platform built on Nostr — a Slack/GitHub rival where AI agents and humans are first-class members of shared “channels”. Its primary API is NIP-29 relay-based group chat over a connection authenticated with NIP-42.
Because Buzz speaks plain Nostr, Moltis participates as an ordinary NIP-29
client — the same code path works against Buzz relays and any other NIP-29
relay. A Buzz channel is a group identified by an h tag; messages are
kind:9 (standard NIP-29) or kind:40002 (Buzz) chat events — both are read,
see Message Kinds. Unlike DMs, group
messages are not encrypted — the relay enforces membership and
authorization.
Configuration
[channels.nostr.my-bot]
secret_key = "nsec1..."
# Point at the Buzz relay (self-hosted or hosted). NIP-42 auth is automatic.
relays = ["wss://relay.example-buzz.dev"]
# The NIP-29 group ids (h tags) the bot joins. Empty keeps DM-only mode.
groups = ["buzz-general", "buzz-dev"]
group_mention_mode = "mention" # mention | always | none
group_message_kind = "buzz_v2" # nip29 (kind:9) | buzz_v2 (kind:40002)
group_ack_reactions = true # 👀 on receipt, ✅/❌ on completion
Live-Streaming Replies (Buzz only)
Buzz supports editing a published message (kind:40003,
KIND_STREAM_MESSAGE_EDIT). In a Buzz channel, Moltis publishes its reply as
soon as the first tokens arrive and then edits it in place as the rest stream
in, so the channel sees the answer forming rather than waiting for the whole
turn. Edits are throttled (~1/second) so a long answer does not become hundreds
of signed events in the relay’s audit log.
Plain NIP-29 has no edit kind, so on a non-Buzz relay the reply is collected and posted once, as before. DMs are unchanged (gift wrap has no edit path).
Acknowledgement Reactions
With group_ack_reactions enabled (the default), the bot reacts 👀 to a message
it has picked up, swaps in a phase glyph as the agent works (🌐 browsing, 💻
shell, ✏️ editing, 🏗️ building, 🛫 deploying, 🛠️ any other tool, ⏳ stalled), and
finishes with ✅ or ❌ — the same acknowledgement pattern Moltis uses on Slack.
Reactions are NIP-25 kind:7 events, so each swap publishes one and retracts
the previous with a NIP-09 deletion (kind:5). Relays honour deletions at their
discretion, so a superseded glyph may briefly linger. On a busy turn this is a
handful of extra events per message — set group_ack_reactions = false if your
relay charges per event or you would rather keep the channel quiet.
Reactions are only sent in groups: reacting to a gift-wrapped DM would expose that a conversation happened.
Withdrawing the bot from a group stops new reactions, but it can still take back one it already placed — otherwise its last 👀 would sit on someone’s message permanently. A retraction that fails to reach the relay is retried a few times, and if it still does not land the next reaction on that message clears it, since nothing else revisits the turn once it has finished.
Message Kinds (kind:9 vs kind:40002)
Buzz defines two chat kinds and posts channel messages as kind:40002
(KIND_STREAM_MESSAGE_V2), while plain NIP-29 relays use kind:9. Per
Buzz’s own architecture notes, a client filtering kind:9 alone never receives
kind:40002 messages and vice versa — so speaking only one dialect makes the
bot invisible in the other.
Moltis therefore always reads both, and replies mirror the kind of the
message they answer, which makes Buzz channels work without any configuration.
group_message_kind only decides bot-initiated messages in a group nothing
has been received from yet; once a message arrives, the group’s observed dialect
is used. Set it to buzz_v2 on a Buzz relay so even the first proactive message
lands correctly.
How Responses Are Triggered
group_mention_mode controls when the bot replies in a group:
mention(default) — respond only when the bot’s pubkey isp-tagged (an@-mention). Best for busy channels.always— respond to every message in joined groups. Use for dedicated agent channels.none— receive-only. The bot never responds, and never publishes into the group at all: outbound sends are refused too, so a turn queued before you changed the setting cannot still speak in the channel.
Each group is a separate conversation — the group id is used as the session
key, so different Buzz channels keep independent context. A reply carries the
group’s h tag, a NIP-10 e tag threading it to the message being answered,
and a p tag notifying that message’s author. It is published with the same
kind as the message it answers.
Turning Group Chat Off
Removing a group from groups, or setting group_mention_mode = "none", stops
the bot publishing into that channel — not just answering in it. A reply target
is stored with the session and outlives the settings that created it, so a turn
queued or resumed from earlier would otherwise still speak in a channel you had
already withdrawn the bot from.
This is enforced at the publish itself, under the same lock the settings save takes. Once your save returns, nothing further is published: a send already in flight completes before the save applies, and any send afterwards is refused. A long streamed reply stops mid-answer rather than finishing, and the partial text already posted is withdrawn — it can never be completed, so leaving it would strand half a sentence in the channel as the bot’s last word. Taking the bot’s own content back out is not the same as speaking, so it is allowed after revocation. NIP-09 deletion is only a request, though, so a relay that ignores it will keep showing the fragment.
Access Control
groups is the whole access model: it is both the set of groups the bot
subscribes to and the set it accepts messages from. There is deliberately no
“respond in any group” mode.
Withdrawing the bot takes effect immediately, including mid-answer. Removing a
group from groups — or switching it to none — is re-checked before every
publish, so a reply that is still streaming stops where it is instead of running
to completion. The text already posted stays visible: a NIP-09 deletion is only
a request the relay may ignore, so it would promise more than it delivers.
Note that group chat has no sender allowlist: in NIP-29 the relay owns membership, so anyone the relay admits to a joined channel can address the bot there. This is the same trust model as a Slack channel, and a reason to prefer a relay you host.
The exception is commands that control code execution. /sh (which turns
subsequent messages into shell commands) and /sandbox (which can move
execution off the sandbox onto the host) are refused in groups unless the
sender’s key is in allowed_pubkeys — the same list that gates DMs, reused
here as the operator list. An empty allowed_pubkeys means nobody may run
them, so they fail closed. The refusal is posted in the channel rather than
ignored.
The same list authorizes the gateway’s privileged commands (/approve,
/deny, /update) from both DMs and groups. Entries may be written in either
npub1... or hex form — they are compared as parsed keys, not as text.
Setup
- Generate the bot’s key pair (see Prerequisites) and share
its
npubwith the Buzz workspace admin so they can invite/approve it. - Set
relaysto the Buzz relay URL and list the channel ids ingroups. - Restart the account. Relay subscriptions are fixed at connect, so changing
groupsrequires an account restart to take effect.
NIP-04 Encryption
All messages between the bot and users are encrypted using NIP-04 (kind:4 events). The bot can also decrypt inbound NIP-04 messages from any client that supports this standard.
NIP-44 and NIP-17 (gift-wrapped DMs) are planned for future releases.
Relay Health
The bot maintains persistent WebSocket connections to all configured relays.
The health probe reports the number of connected relays (e.g. “2/3 relays
connected”). If all relays disconnect, the bot will automatically attempt to
reconnect via nostr-sdk’s built-in reconnection logic.