Appearance
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.
| Protocol | Specification | What it is | Status in kernl |
|---|---|---|---|
| Encrypted direct messages | NIP-04 (deprecated upstream in favour of NIP-17) | Kind 4, shared-secret encryption, sender and recipient public | Shipped: legacy |
| Private direct messages | NIP-17, with NIP-44 encryption and NIP-59 gift wrap | Kind 14 rumours sealed and gift-wrapped, one or many recipients | Shipped: private (text; kind 15 file messages are not read yet) |
| Double-ratchet sessions | nostr-double-ratchet (draft, outside the NIPs) | One-to-one sessions whose keys change with every message | Shipped: ratchet, experimental |
| MLS groups | Marmot protocol (MIP-00 to MIP-03; supersedes NIP-EE, marked unrecommended upstream) | MLS (RFC 9420) groups over kinds 30443, 444 and 445 | Shipped: group, experimental |
| Relay-based groups | NIP-29 | Groups a relay enforces; content is readable by the relay, not end-to-end encrypted | Not 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 channels | NIP-28 (unrecommended upstream) | Public channel messages, kind 42 | Not private messaging; out of scope |
| Chats, public messages, voice messages | NIP-C7, NIP-A4, NIP-A0 | Public kinds 9, 24 and 1222 | Not private messaging; out of scope. NIP-C7's kind 9 is the chat convention group carries inside its encryption |
| Ephemeral gift wrap | NIP-59 kind 21059 | A gift wrap relays need not store | A 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:
| Field | Meaning |
|---|---|
forwardSecrecy | Compromise of today's keys does not reveal yesterday's messages. |
postCompromiseSecurity | The session heals after a compromise once both parties exchange messages. |
metadataProtection | none, partial or strong: how much a network observer learns about who talks to whom and when. |
requiresSameClient | The correspondent must use a client that speaks this provider. |
groups | Conversations with more than two participants are supported. |
specification | Optional. Where the protocol is specified. Shown in these docs, not in the public UI. |
experimental | The 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 id | Name | specification | What the specification is | Forward secrecy | Post-compromise security | Metadata protection | Same client required | Groups | Experimental |
|---|---|---|---|---|---|---|---|---|---|
private | Private messages | 'NIP-17' | Gift-wrapped private messages: kind 14 rumours sealed in kind 13, wrapped in kind 1059 with a random key | no | no | partial: the wrap hides sender and timing from relays, the recipient's inbox relays still see delivery | no | yes | no |
legacy | Legacy messages | 'NIP-04' | Direct messages: kind 4, shared-secret encryption | no | no | none: sender, recipient and timing are public on the event | no | no | no |
ratchet | Ratchet sessions | 'nostr-double-ratchet (draft)' | A double-ratchet session protocol over nostr, still a draft | yes | yes | partial: per-message ephemeral keys hide the long-term identities, relays still see timing | yes | no | yes |
group | Group 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) | yes | yes | partial: 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 sessions | yes | yes | yes |
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:
legacyis 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.privatehas no forward secrecy. A gift wrap protects metadata, not past messages, and the picker does not claim otherwise.privateandlegacyare available only while the active account's signer can encrypt:nip44forprivate,nip04forlegacy. Otherwise kernl reportssign-in-required(nobody is signed in) orsigner-cannot-encrypt(a read-only account, or a signer without the method).ratchetis available only when the shell'savailable()says so. The package does not decide the reason; kernl'screateDefaultMessagingreportssign-in-requiredorsigner-cannot-encryptlikeprivate, because the backend needsnip44to seal its state, andno-runtime(MESSAGING_UNAVAILABLE_REASONS.noRuntime) only when a host registers it without a backend;enable()rethrows the reason asunavailable:<reason>, andlist()reports it so the picker can show the provider greyed with that reason. Its specification is a draft and the provider is flaggedexperimental.groupfollows the signer the same way (nip44opens invitations and seals the library's state) and reportsno-runtimeonly when a host registers it without a backend. Its library is an alpha release, so the provider is flaggedexperimental.
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:
createPrivateMessagesBackendsends 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 itsnip44; the seal is wrapped in a kind 1059 event signed by a one-time key. That key is generated inside an applesaucePrivateKeySigner, 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'soutboxandsupertiers 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'screateConversationIdentifier: 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 theEventStore, and are cleared when the account changes or signs out; theEventStoreis read only for inbox relay lists.createLegacyMessagesBackendsends NIP-04 kind 4 messages throughsigner.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.createRatchetMessagesBackendruns thenostr-double-ratchetlibrary'sNdrRuntimefor 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 throughcreateSealedRatchetStorage, which encrypts each value to the account with itsnip44under 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'soutboxandsupertiers, 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.createGroupMessagesBackendruns@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 throughcreateSealedGroupStore, which encrypts the whole store to the account with itsnip44, so storage holds no readable key. Opening the backend publishes a key package (kind 30443) to the user's inbox relays andoutboxtier, 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 withunavailable:recipient-not-ready. Group events go to theoutboxandsupertiers; 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 savedpreferences.enabledlist names is started at once if it is available. One that cannot run yet (nobody is signed in, say) waits: it reports why throughlist(), 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 throwsinvalid:duplicate-provider.enable(id)callsavailability()and throwsunavailable:<reason>when the provider cannot run; otherwise it callscreateBackend()once and keeps the instance. Enabling an enabled provider does nothing. Unknown ids throwinvalid:unknown-provider.disable(id)calls the backend'sclose?()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 throwsinvalid:unknown-providerand 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 throwsinvalid:provider-not-enabledfor one that is not running. setReplyRule(rule)andforgetReplyChoice(pubkey?)change how a reply is routed; see Which provider carries a message.preferences()returns a frozen{ enabled, order, defaultProvider?, reply, replyChoices }snapshot;onChangereceives the same snapshot after every change.enabledlists what the user wants on, running or waiting. Snapshots saved before there was an order carry onlyenabledanddefaultProvider, and restore with that default on top.composite()returns oneMessagingBackendspanning every running provider, for the NAPdmservice. 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:
| Rule | What 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. |
theirs | The reply leaves through the provider they used, without asking. |
mine | The 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 bynamespaceId.private:direct:ab,cdis the conversationdirect:ab,cdas theprivateprovider knows it. The inner id may itself contain:;splitIdsplits 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 byupdatedAtdescending (conversations without activity sort last),limitis 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 theproviderfield (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 throughoptions.chooseReply; see Which provider carries a message. An unknown provider rejects withinvalid:unknown-provider; no default, or a default that is no longer enabled, rejects withunavailable: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 arecomposite-<n>. Unsubscribing skips providers disabled in the meantime, since disabling already closed their backend, and reportsok: falseif any surviving provider did.status()merges:availableif any provider is, the firstownerPubkeyfound, the union ofimplementationsandcapabilities.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
| Reason | Thrown by |
|---|---|
invalid:duplicate-provider | register |
invalid:unknown-provider | enable, 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-enabled | setDefault |
unavailable:no-default-provider | composite send with no conversation, no hint and no usable default |
unavailable:recipient-not-ready | composite send to someone new when no enabled provider from the default onward can reach them; the first provider's refusal is passed through |
denied:cancelled | composite send when the user dismissed the reply question |
invalid:no-recipients, invalid:unknown-conversation, unavailable:offline | memory backend send |