Skip to content

Add a messaging provider ​

A provider is a package change plus a docs page. This how-to adds a provider called example and wires it into the shell's registry.

1. Describe it truthfully ​

Start with the properties, because they are the contract with the user. Every field except specification is required, and the shell shows all of them except specification through describeProvider.

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

export const EXAMPLE_PROPERTIES: SecurityProperties = {
  forwardSecrecy: false,
  postCompromiseSecurity: false,
  metadataProtection: 'partial',
  requiresSameClient: true,
  groups: false,
  specification: 'https://example.org/spec',
  experimental: true,
};

export const EXAMPLE_DEFINITION: ProviderDefinition = {
  id: 'example',
  name: 'Example messages',
  description: 'Messages readable by other people using this shell.',
  properties: EXAMPLE_PROPERTIES,
};

If you are unsure whether a property holds, it does not. A provider that overstates its protection fails the review, however good its code is. name and description are shown in the public UI, so they follow the public vocabulary: no protocol names. specification is where the protocol name goes; describeProvider never renders it.

2. Implement the backend ​

A MessagingBackend has six methods and an optional close. Ids you return are provider-local; the composite prefixes them with example:. Wire types come from @napplet/core. The backend talks to whatever the shell hands it; here that is a small ExampleDeps object so the sample stays free of any relay library. The samples on this page are one module, read top to bottom.

ts
import type { DmMessage, DmSendRequest, DmSendResult } from '@napplet/core';
import type { MessagingBackend } from '@kernlize/messaging';

export interface ExampleDeps {
  readonly ownerPubkey: string;
  readonly publish: (request: DmSendRequest) => Promise<DmMessage>;
  readonly history: (conversationId: string) => readonly DmMessage[];
  readonly onIncoming: (listener: (message: DmMessage) => void) => () => void;
}

export function createExampleBackend(deps: ExampleDeps): MessagingBackend {
  const subscriptions = new Map<string, () => void>();
  let count = 0;
  return {
    status: () => ({ available: true, ownerPubkey: deps.ownerPubkey, implementations: ['example'], capabilities: ['send', 'receive', 'subscribe', 'history'] }),
    conversations: () => ({ conversations: [] }),
    messages: (query) => ({ messages: [...deps.history(query.conversationId)] }),
    async send(request): Promise<DmSendResult> {
      if (request.recipients.length === 0) throw new Error('invalid:no-recipients');
      return { ok: true, message: await deps.publish(request) };
    },
    subscribe(request, onMessage) {
      count += 1;
      const subscriptionId = `example-${count}`;
      subscriptions.set(
        subscriptionId,
        deps.onIncoming((message) => {
          if (request.conversationId === undefined || message.conversationId === request.conversationId) onMessage(message);
        }),
      );
      return { subscriptionId };
    },
    unsubscribe(subscriptionId) {
      const stop = subscriptions.get(subscriptionId);
      stop?.();
      return { ok: subscriptions.delete(subscriptionId) };
    },
    close() {
      for (const stop of subscriptions.values()) stop();
      subscriptions.clear();
    },
  };
}

Rules the backend must keep:

  • Use the shell's shared EventStore and RelayPool (kernl.eventStore, kernl.pool); do not open a second relay connection. The shell does not inject anything into your backend: @kernlize/core calls only the factory you register, so you build ExampleDeps yourself from those instances, as exampleDeps(resolve) below does.
  • Sign only through the active account's signer (kernl.accounts.active?.signer), wrapped in a function you put in ExampleDeps such as publish. Never touch key bytes.
  • Reject with stable reasons: denied:, invalid:, unavailable:.
  • subscribe returns { subscriptionId }; unsubscribe of an unknown id returns { ok: false }; close drops every subscription. Every method except send may be synchronous.
  • Page with cursor and limit. The composite passes your cursor through when you are the only enabled provider, so make it a string you can resume from.

Use createMemoryBackend as the reference for the semantics the features expect, and as the backend in your own tests.

3. Build the provider with defineProvider ​

The registry calls createBackend() once per enable and available() whenever it needs to know. Both are injected, so this package never learns what a session is:

ts
import { defineProvider, type ProviderDeps } from '@kernlize/messaging';

export function exampleDeps(resolve: () => ExampleDeps | undefined): ProviderDeps {
  return {
    createBackend: () => {
      const deps = resolve();
      if (!deps) throw new Error('unavailable:signed-out');
      return createExampleBackend(deps);
    },
    available: () => (resolve() ? { available: true } : { available: false, reason: 'signed-out' }),
  };
}

// Set by your sign-in code when a session starts, cleared on sign-out.
let sessionDeps: ExampleDeps | undefined;

export const provider = defineProvider(EXAMPLE_DEFINITION, exampleDeps(() => sessionDeps));

The reason is a bare word. The registry prefixes it: enable('example') throws unavailable:signed-out, and list() reports { available: false, reason: 'signed-out' } so the picker can grey the provider and say why.

The three built-ins (createPrivateMessagesProvider, createLegacyMessagesProvider, createRatchetMessagesProvider) are exactly this shape: a fixed definition in the package, ProviderDeps from the shell. If your provider ships in @kernlize/messaging, add createExampleMessagesProvider(deps: ProviderDeps) next to them in packages/messaging/src/providers/ and export it from providers/index.ts.

4. Register it ​

The three built-in providers are registered by createDefaultMessaging in packages/core/src/messaging/default-registry.ts, which createKernl calls when KernlOptions.messaging is absent. You do not edit createKernl. A host has two options-based paths:

  • A backend for a built-in provider. Pass KernlOptions.messagingBackends, a MessagingBackendFactories ({ private?, legacy?, ratchet? }, each () => MessagingBackend). Your factory replaces kernl's backend for that provider (createPrivateMessagesBackend or createLegacyMessagesBackend by default); availability still follows the active account's signer.
  • A new provider. Build the default registry yourself, register yours on it, and pass it as KernlOptions.messaging. The defaults stay registered:
ts
import { createDefaultMessaging, createKernl, type KernlHost } from '@kernlize/core';

declare const host: KernlHost;

const messaging = createDefaultMessaging({ storage: host.storage });
messaging.register(provider);
const kernl = createKernl({ host, messaging });

When messaging is supplied, createKernl uses it as is and ignores messagingBackends; pass any built-in backends to createDefaultMessaging({ storage, backends }) instead. To ship a provider inside kernl itself, register it in createDefaultMessaging next to the three built-ins.

Registration does not enable, unless the user's saved preferences already list example; then it starts on registration if available() agrees, and otherwise waits until it does. A new provider joins the end of the user's order. The user switches it on, and moves it, under Settings, Messages, which calls registry.enable('example') and registry.setOrder([...]). Registering the same id twice throws invalid:duplicate-provider. See registry.feature, scenarios "a registered provider starts disabled", "saved preferences enable providers as they register" and "a waiting provider starts as soon as it can run".

5. Specify and document ​

  • Add the provider to the Scenario Outline in packages/messaging/features/providers.feature ("the built-in providers carry fixed copy and properties") with its name, description, six properties and specification, and a scenario that describeProvider rows name no protocol. Step definitions live in packages/messaging/features/steps/.
  • If the backend lives in a package, give it a feature file modelled on memory-backend.feature: sends create conversations, delivered messages are unread, subscriptions filter by conversation, cursors resume.
  • Add a row to the providers table in Messaging and a line to the providers table on the package page.
  • Add a changeset for @kernlize/messaging (and @kernlize/core if you touched createDefaultMessaging).

pnpm test must stay at 100% coverage over packages/*/src, and pnpm docs:check must pass. A provider without the feature rows or the docs rows is not shipped.

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