Skip to content

Messaging ​

No encrypted messaging protocol is inherently superior. Each one trades metadata exposure, forward secrecy and reach against the others, and the right trade depends on who the user is writing to. kernl therefore supports several and ranks none (D-009). A provider describes its own properties truthfully; the shell shows the description and lets the user choose.

In this alpha private, legacy and ratchet work in the live shell when the signed-in account's signer can encrypt; ratchet is experimental. The offline preview runs private and legacy over the in-memory backend, and messages sent there go nowhere; it reports ratchet unavailable (Alpha status).

The live shell also runs group (group sessions, experimental) for a signed-in account whose signer has nip44; the preview reports it as needing a sign-in.

Protocol inventory ​

kernl aims to carry every private-messaging protocol that runs over the identity layer, each as one provider behind the same contract (D-018). This is every messaging NIP in the NIPs index as of November 2026, plus the two session protocols specified outside it.

ProtocolSpecificationWhat it isStatus in kernl
Encrypted direct messagesNIP-04 (deprecated upstream in favour of NIP-17)Kind 4, shared-secret encryption, sender and recipient publicShipped: legacy
Private direct messagesNIP-17, with NIP-44 encryption and NIP-59 gift wrapKind 14 rumours sealed and gift-wrapped, one or many recipientsShipped: private (text; kind 15 file messages are not read yet)
Double-ratchet sessionsnostr-double-ratchet (draft, outside the NIPs)One-to-one sessions whose keys change with every messageShipped: ratchet, experimental
MLS groupsMarmot protocol (MIP-00 to MIP-03; supersedes NIP-EE, marked unrecommended upstream)MLS (RFC 9420) groups over kinds 30443, 444 and 445Shipped: group, experimental
Relay-based groupsNIP-29Groups a relay enforces; content is readable by the relay, not end-to-end encryptedNot a provider: no end-to-end encryption, and a group lives on one relay rather than among people, which the dm recipient model (people, by public key) does not express. Recorded in D-018
Public chat channelsNIP-28 (unrecommended upstream)Public channel messages, kind 42Not private messaging; out of scope
Chats, public messages, voice messagesNIP-C7, NIP-A4, NIP-A0Public kinds 9, 24 and 1222Not private messaging; out of scope. NIP-C7's kind 9 is the chat convention group carries inside its encryption
Ephemeral gift wrapNIP-59 kind 21059A gift wrap relays need not storeA transport detail, not a protocol of its own; no provider sends it yet

The provider contract ​

A MessagingProvider in @kernlize/messaging is a pluggable module with one contract:

ts
import type { MessagingBackend, ProviderAvailability, SecurityProperties } from '@kernlize/messaging';

interface MessagingProvider {
  readonly id: string;              // conversation-id prefix: 'private', 'legacy', 'ratchet'
  readonly name: string;            // user-facing, no protocol vocabulary
  readonly description: string;     // one sentence for the picker
  readonly properties: SecurityProperties;
  availability(): ProviderAvailability;
  createBackend(): MessagingBackend;
}

createBackend() returns a MessagingBackend: status, conversations, messages, send, subscribe, unsubscribe and an optional close. The shape is structurally identical to kehto's DmAdapter, so a backend can be handed to createDmService without translation while this package stays free of a kehto dependency. The wire types (DmConversation, DmMessage, DmSendRequest and friends) come from @napplet/core. Every method except send may return a value or a promise.

The package holds no relay code, signer or store. A provider is built with defineProvider(definition, deps), where definition is the fixed, user-facing part (id, name, description, properties) and deps is what the shell injects:

ts
import type { MessagingBackend, ProviderAvailability } from '@kernlize/messaging';

interface ProviderDeps {
  createBackend: () => MessagingBackend;     // called once per enable; the registry owns the instance
  available: () => ProviderAvailability;     // { available: true } or { available: false, reason }
}

The three built-in factories (createPrivateMessagesProvider, createLegacyMessagesProvider, createRatchetMessagesProvider) take the same ProviderDeps and supply the definition themselves. In the shell, @kernlize/core builds each provider's ProviderDeps: createKernl supplies createPrivateMessagesBackend and createLegacyMessagesBackend unless KernlOptions.messagingBackends overrides them, and available follows the active account (The shell's backends). In tests and the preview, createBackend returns createMemoryBackend(). See providers.feature, scenario "availability and the backend come from the shell".

Security properties ​

SecurityProperties is a fixed set of fields. Every field except specification is required, and the shell renders them verbatim with no summary score:

FieldMeaning
forwardSecrecyCompromise of today's keys does not reveal yesterday's messages.
postCompromiseSecurityThe session heals after a compromise once both parties exchange messages.
metadataProtectionnone, partial or strong: how much a network observer learns about who talks to whom and when.
requiresSameClientThe correspondent must use a client that speaks this provider.
groupsConversations with more than two participants are supported.
specificationOptional. Where the protocol is specified. Shown in these docs, not in the public UI.
experimentalThe provider is still changing and may lose history between versions.

describeProvider(provider) turns them into the six rows the picker shows, in this order and with these labels: Forward secrecy, Recovers after a key compromise, Metadata protection, Correspondent needs the same app, Group conversations, Experimental. Booleans render as yes/no; metadataProtection renders as is; specification is left out so no protocol name reaches the UI (D-006).

The three providers ​

Provider idNamespecificationWhat the specification isForward secrecyPost-compromise securityMetadata protectionSame client requiredGroupsExperimental
privatePrivate messages'NIP-17'Gift-wrapped private messages: kind 14 rumours sealed in kind 13, wrapped in kind 1059 with a random keynonopartial: the wrap hides sender and timing from relays, the recipient's inbox relays still see deliverynoyesno
legacyLegacy messages'NIP-04'Direct messages: kind 4, shared-secret encryptionnononone: sender, recipient and timing are public on the eventnonono
ratchetRatchet sessions'nostr-double-ratchet (draft)'A double-ratchet session protocol over nostr, still a draftyesyespartial: per-message ephemeral keys hide the long-term identities, relays still see timingyesnoyes
groupGroup sessions'Marmot (MLS, RFC 9420; supersedes NIP-EE)'MLS groups over nostr: key packages (kind 30443), invitations gift-wrapped as kind 444 rumours, group events (kind 445)yesyespartial: group events are signed by one-time keys and carry only the group's id, so relays see a group's activity and timing but not its members; a published key package says the account uses group sessionsyesyesyes

The descriptions the picker shows: private "Sealed messages that hide who is talking to whom from the network."; legacy "The original direct-message format. Readable by the widest range of apps, but who you talk to is visible on the network."; ratchet "Per-session keys that change with every message. Both sides need an app that supports it."; group "Shared keys that change as the group talks and as people join or leave. Everyone needs an app that supports it."

Three things the table says that the picker says too:

  • legacy is marked as metadata-exposing because it is. It exists because many correspondents can read nothing else, and the user is told that before choosing it.
  • private has no forward secrecy. A gift wrap protects metadata, not past messages, and the picker does not claim otherwise.
  • private and legacy are available only while the active account's signer can encrypt: nip44 for private, nip04 for legacy. Otherwise kernl reports sign-in-required (nobody is signed in) or signer-cannot-encrypt (a read-only account, or a signer without the method).
  • ratchet is available only when the shell's available() says so. The package does not decide the reason; kernl's createDefaultMessaging reports sign-in-required or signer-cannot-encrypt like private, because the backend needs nip44 to seal its state, and no-runtime (MESSAGING_UNAVAILABLE_REASONS.noRuntime) only when a host registers it without a backend; enable() rethrows the reason as unavailable:<reason>, and list() reports it so the picker can show the provider greyed with that reason. Its specification is a draft and the provider is flagged experimental.
  • group follows the signer the same way (nip44 opens invitations and seals the library's state) and reports no-runtime only when a host registers it without a backend. Its library is an alpha release, so the provider is flagged experimental.

The shell's backends ​

@kernlize/core ships the three network backends the live shell uses (D-015). Both take the shell's one EventStore, RelayPool and relay tiers, and read the signer and the user's public key through functions, so they follow the active account without being rebuilt:

  • createPrivateMessagesBackend sends NIP-17 messages. The message is an unsigned kind 14 rumour; it is sealed in a kind 13 event that the account signer signs, encrypting with its nip44; the seal is wrapped in a kind 1059 event signed by a one-time key. That key is generated inside an applesauce PrivateKeySigner, used for one wrap and discarded; it is never stored. The wrap goes to the recipient's kind 10050 inbox relays, or to the sender's outbox and super tiers when the recipient publishes none, and a second wrap goes to the sender so their other devices see the message. The backend subscribes to kind 1059 events tagged with the user's public key on the user's own inbox relays, or the same tiers. A conversation id is applesauce's createConversationIdentifier: the participants' public keys, the user's included, sorted and joined by :, so both sides compute the same id. Decrypted messages are kept in memory per account, never in the EventStore, and are cleared when the account changes or signs out; the EventStore is read only for inbox relay lists.
  • createLegacyMessagesBackend sends NIP-04 kind 4 messages through signer.nip04, with the same conversation ids and message shape. It is the metadata-exposing provider: who writes to whom, and when, is public on the event.
  • createRatchetMessagesBackend runs the nostr-double-ratchet library's NdrRuntime for the signed-in account (D-016). The account signer signs the account's device list; the library generates and holds this browser's device key and every per-session ratchet key. Those secrets are written only through createSealedRatchetStorage, which encrypts each value to the account with its nip44 under an opaque slot number, so storage holds no readable key and no correspondent's name. Opening the backend registers this browser as a device and publishes its invite; the other side starts a session from it. Traffic uses the user's outbox and super tiers, so both sides must share a relay. Sessions are one to one, only chat messages are filed (typing notices and receipts are dropped), and the conversation list is in memory: sessions survive a reload, the list does not.
  • createGroupMessagesBackend runs @internet-privacy/marmot-ts, the maintained TypeScript Marmot implementation, for the signed-in account (D-018). The account signer signs each member's identity proof and key package and opens invitations; the library generates and holds its own MLS signing keys and the group secrets, which move on with every commit. Every store the library keeps is written through createSealedGroupStore, which encrypts the whole store to the account with its nip44, so storage holds no readable key. Opening the backend publishes a key package (kind 30443) to the user's inbox relays and outbox tier, and a fresh one after an invitation uses it up. A send to people goes to the group with exactly those members, else founds one and invites each person by their newest key package; someone with none is refused with unavailable:recipient-not-ready. Group events go to the outbox and super tiers; invitations arrive as gift wraps on the user's inbox relays and are joined as they open. The conversation id is the group id in hex. An account is one member of a group, so only one of a person's devices joins it. Group membership and secrets survive a reload; the messages are kept in memory, so a reload starts each group's history afresh.

kernl holds no key bytes. The private and legacy backends sign and encrypt everything with the account's applesauce signer; the ratchet and group libraries' own keys stay inside the libraries and reach storage only sealed by that signer; an extension or remote signer may ask its own question per decryption, and kernl lets it.

The shell draws no conversation itself. Messages are shown by an applet over the NAP dm domain, like any other applet would: see The Messages applet. An applet reaches messages only through that domain, which is a grant per applet that the user can revoke (Identity and grants). The shell's own part is what an applet must not decide: which providers are on, in what order, and which one carries a reply.

The registry ​

The shell holds exactly one MessagingRegistry, built with createMessagingRegistry({ preferences?, onChange?, askReply? }). It owns the backend instances:

  • register(provider) adds a provider at the end of the user's order, unless the saved order already places it. A provider the saved preferences.enabled list names is started at once if it is available. One that cannot run yet (nobody is signed in, say) waits: it reports why through list(), stays in the saved list, and starts the next time the registry is read after it became available. A second registration of the same id throws invalid:duplicate-provider.
  • enable(id) calls availability() and throws unavailable:<reason> when the provider cannot run; otherwise it calls createBackend() once and keeps the instance. Enabling an enabled provider does nothing. Unknown ids throw invalid:unknown-provider.
  • disable(id) calls the backend's close?() and drops it, or forgets a provider that was still waiting.
  • setOrder(ids) puts the named providers first, in that order; providers left out keep their relative order after them. list() returns the providers in the user's order. An unknown id throws invalid:unknown-provider and changes nothing.
  • The default is the first enabled provider in the order. There is no separate default to set. setDefault(id) is kept as "move this enabled provider to the top" and throws invalid:provider-not-enabled for one that is not running.
  • setReplyRule(rule) and forgetReplyChoice(pubkey?) change how a reply is routed; see Which provider carries a message.
  • preferences() returns a frozen { enabled, order, defaultProvider?, reply, replyChoices } snapshot; onChange receives the same snapshot after every change. enabled lists what the user wants on, running or waiting. Snapshots saved before there was an order carry only enabled and defaultProvider, and restore with that default on top.
  • composite() returns one MessagingBackend spanning every running provider, for the NAP dm service. It is the same instance on every call and sees providers come and go.
  • close() closes every backend and leaves the preferences untouched, so a shutdown never erases what the user chose. A closed registry is not reused.

See registry.feature, scenarios "the default is the first provider in the order that is enabled", "a waiting provider starts as soon as it can run" and "closing the registry closes every backend and keeps the preferences".

createDefaultMessaging in @kernlize/core registers private, legacy, ratchet and group in that order. For someone who has never chosen, private and legacy are switched on: they reach anyone with an account, and a message that arrives through either can be read and answered. ratchet and group publish something about the account when they start, so they wait for the user (D-027).

Which provider carries a message ​

NAP-DM leaves the choice of transport to the runtime, and requires that a runtime with several of them never weakens privacy silently. kernl's rule has two halves (D-027), both in reply.feature:

Someone new: the order decides. A dm.send that names recipients and no conversation goes to the first enabled provider. When that provider answers unavailable:recipient-not-ready (the person has not set it up), the next enabled one is tried, in order; when none can reach them the first refusal is reported. Any other refusal stops the send. Providers ahead of the default are never tried.

A reply: the correspondent's last provider is offered. Before a one-to-one message leaves, the composite looks up which enabled provider that person last wrote through (their newest incoming message; a tie goes to the earlier provider in the order). When it is not the provider the message is planned for, the reply rule decides:

RuleWhat happens
ask (the default)The registry calls askReply({ peer, theirs, mine }). The host shows the question; the answer is { use: 'theirs' | 'mine', remember: 'once' | 'conversation' | 'always' }, and undefined stops the send with denied:cancelled.
theirsThe reply leaves through the provider they used, without asking.
mineThe reply leaves through the provider the order picked, without asking.

An answer kept for the conversation is stored per person in replyChoices and comes before the rule; always makes the answer the rule. forgetReplyChoice(pubkey) forgets one person's answer and forgetReplyChoice() all of them. Without askReply the rule ask behaves like mine.

The check applies to a send with one recipient other than the owner, and to a send into a direct conversation with one other participant: there, a reply moved to their provider is sent to the person, not into the conversation that was named, so its result carries the other provider's conversation id. It never applies to a group conversation, a message to several people, a send that names a provider, or when fewer than two providers are running. Someone who never wrote to the user gets the order's provider with no question.

In the shell the question is a dialog: who wrote, through what, what the order would do, the two providers with their descriptions, and whether to keep the answer for this message, this person or everyone. Settings, Messages holds the order, the switches, the rule and the button that forgets kept answers.

The Messages applet ​

Pressing Messages opens an applet. kernl ships one, applets/messages in the repository, built with the napplet toolchain into a single file and listed in KernlOptions.builtins, so it opens as kernl:messages from bytes pinned by hash, with no lookup (Applets under kernl, D-027). It requires only dm; identity, theme, notify, storage and keys are optional and each has a fallback. On its first run dm, identity and notify are switched on, because the applet came with the shell the user is already trusting; each stays a grant the user can withdraw from the window's Grants.

The applet shows one thread per person: it merges every direct conversation with the same participants, whichever provider each belongs to, and sends a reply to the person without naming a conversation, so the shell's rule above picks the transport. Group conversations stay one thread each and are answered in place. It never parses a conversation id, never learns a provider's name, and counts unread messages itself, because a provider's unread number is a lifetime count.

If the user made another installed applet their default for the dm role (Applets, Yours), Messages opens that one instead; when it cannot be reached, the shipped applet opens. The count on the Messages button is the shell's own: messages from other people that arrived, after Messages was first opened, while no window showing messages had focus.

The composite backend ​

The NAP dm domain is a projection over every enabled provider. createCompositeBackend(resolveEnabled, getDefault, options?) builds it, and the only thing an applet can observe is the id namespace:

  • Every conversation and message id leaving the composite is "<providerId>:<innerId>", built by namespaceId. private:direct:ab,cd is the conversation direct:ab,cd as the private provider knows it. The inner id may itself contain :; splitId splits on the first one only.
  • Ids arriving from the applet are split to find the provider. An id without a prefix, or with a prefix no enabled provider owns, rejects with invalid:unknown-provider.
  • conversations() with a cursor routes to the provider named in the cursor and passes the inner cursor through. Without a cursor and with exactly one provider enabled, the provider's page is returned with its cursor namespaced (private:2). With several providers, every provider's page is fetched, the conversations are merged and sorted by updatedAt descending (conversations without activity sort last), limit is applied to the merged list, and the page carries no cursor, because no single provider can resume a merged page.
  • messages() routes by the conversation prefix and prefixes the ids on the way out.
  • send() routes by the conversation id when there is one, else by the provider field (WithProvider<DmSendRequest>) a caller may add, else by the default provider and the order after it. A one-to-one send is first checked against the correspondent's last provider through options.chooseReply; see Which provider carries a message. An unknown provider rejects with invalid:unknown-provider; no default, or a default that is no longer enabled, rejects with unavailable:no-default-provider.
  • subscribe() with a conversation id subscribes on that provider; without one it subscribes on every enabled provider and fans the messages in, prefixed. Subscription ids are composite-<n>. Unsubscribing skips providers disabled in the meantime, since disabling already closed their backend, and reports ok: false if any surviving provider did.
  • status() merges: available if any provider is, the first ownerPubkey found, the union of implementations and capabilities.
  • close() on the composite is a no-op. The registry owns backend lifetimes.

Every provider is reachable this way: kernl.feature drives the dm domain over a real applet port for ratchet and for group (grant dm, then dm.status, dm.send with provider, dm.subscribe, dm.conversations and dm.messages) and checks the provider-namespaced ids. The fields that choose a provider are the provider hint on dm.send for a new conversation and the <providerId>: prefix of conversationId everywhere else; dm.status lists each enabled provider's implementation (nip17, nip04, ndr, marmot).

An applet that uses dm therefore sees one inbox. Which provider carries a given conversation is encoded in its id, which an applet treats as opaque; the user sets the order and the reply rule in the shell's settings. See composite.feature and reply.feature.

The in-memory backend ​

createMemoryBackend({ owner?, available?, now? }) is a complete MessagingBackend with no network, used by the shell's offline preview and by every feature in this package. Conversations are keyed direct:<pubkey> or group:<pubkey>,<pubkey> over the sorted correspondents with the owner removed; send() stores the message as sent (id msg-<n>, or the request's clientMessageId) and notifies subscribers; deliver({ senderPubkey, content, conversationId?, createdAt? }) plays the other side and stores a received message; unread counts are the number of received messages; cursors are numeric offsets with default page sizes of 50 conversations and 100 messages. send() rejects with invalid:no-recipients, invalid:unknown-conversation or unavailable:offline; reads keep working offline so a preview can still show history. See memory-backend.feature.

Error reasons ​

ReasonThrown by
invalid:duplicate-providerregister
invalid:unknown-providerenable, disable (unknown id); composite routing of ids, cursors and provider hints
unavailable:<reason>enable when availability() is false; <reason> is the shell's word
invalid:provider-not-enabledsetDefault
unavailable:no-default-providercomposite send with no conversation, no hint and no usable default
unavailable:recipient-not-readycomposite send to someone new when no enabled provider from the default onward can reach them; the first provider's refusal is passed through
denied:cancelledcomposite send when the user dismissed the reply question
invalid:no-recipients, invalid:unknown-conversation, unavailable:offlinememory backend send

MIT licensed. Protocol authority: NIP-5D (nostr-protocol/nips PR #2303) and the NAPs (napplet/naps).