Moltis
Features

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:

  1. Generate a new key pair using any Nostr client (e.g. Damus, Amethyst, or a key generation tool)
  2. Copy the secret key — either the nsec1... bech32 format or the 64-character hex format
  3. 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

FieldRequiredDefaultDescription
secret_keyyesNostr secret key (nsec1... bech32 or 64-char hex)
relaysno["wss://relay.damus.io", "wss://relay.nostr.band", "wss://nos.lol"]Relay WebSocket URLs to connect to
dm_policyno"allowlist"Who can DM the bot: "open", "allowlist", or "disabled"
allowed_pubkeysno[]Public keys allowed to DM (npub1... or hex, when dm_policy = "allowlist")
enablednotrueWhether this account is active
modelnoOverride the default model for this channel
model_providernoProvider for the overridden model
otp_self_approvalnotrueAllow non-allowlisted senders to self-approve via OTP code
otp_cooldown_secsno300Cooldown after 3 failed OTP attempts
groupsno[]NIP-29 group ids (h tags) to join — see Buzz & NIP-29 Groups. Empty = DM-only
group_mention_modeno"mention"When to respond in groups: "mention" (p-tagged only), "always", or "none"
group_message_kindno"nip29"Dialect for bot-initiated group messages: "nip29" (kind:9) or "buzz_v2" (kind:40002)
group_ack_reactionsnotrueReact 👀 on receipt and ✅/❌ on completion in groups (NIP-25)
profile.namenoNIP-01 profile display name
profile.display_namenoNIP-01 longer display name
profile.aboutnoNIP-01 bio / about text
profile.picturenoNIP-01 avatar URL (HTTPS)
profile.nip05noNIP-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 in allowed_pubkeys can message the bot. Unknown senders receive an OTP challenge if otp_self_approval is 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 is p-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

  1. Generate the bot’s key pair (see Prerequisites) and share its npub with the Buzz workspace admin so they can invite/approve it.
  2. Set relays to the Buzz relay URL and list the channel ids in groups.
  3. Restart the account. Relay subscriptions are fixed at connect, so changing groups requires 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.