Reference service handlers for the napplet protocol — audio, notifications, identity, relay pool, cache, keys, media, notify, theme, link, common, lists, serial, BLE, WebRTC.
Alpha status: Kehto is an early runtime implementation for a draft NIP-5D protocol. NAP contracts and service envelopes are not final; treat these handlers as reference implementations for the current draft.
pnpm add @kehto/services
@kehto/services ships the reference implementations of the ServiceHandler contract defined by @kehto/runtime. Each factory returns an object that the runtime routes NIP-5D envelopes to based on the domain prefix of the incoming message type (e.g., identity.* goes to the handler registered under identity).
Host apps wire services into the runtime via runtime.registerService(name, handler). The services are browser-agnostic — they have no DOM dependency. Browser-specific behaviors (audio element pool, OS notifications) are delivered through host-supplied callbacks.
Current draft posture:
createIdentityService (getPublicKey, getRelays, getProfile, getFollows, getList, getZaps, getMutes, getBlocked, getBadges); signing happens inside the shell as part of relay.publish / relay.publishEncrypted and is never exposed to napplets.createKeysService and createMediaService ship real reference backends as of v1.4 (see the dedicated sections below). createKeysService attaches a document-level keydown listener by default and delivers keys.action push envelopes to registered napplets; createMediaService mirrors session metadata and playback state to navigator.mediaSession and emits media.command push envelopes on OS transport events. Both accept a host-bridge option (HostKeysBridge / HostMediaBridge) so Electron / Tauri / native shells can swap in OS-level backends without re-implementing the wire-protocol bookkeeping.createNotifyService (NIP-5D notify.* NAP) coexists with the legacy createNotificationService (inc-emit notifications:* channel). Both may be registered simultaneously while hosts migrate.createResourceService supports resource.bytesMany from draft NAP-RESOURCE. Bulk requests return ordered per-URL items and keep per-URL failures local while preserving legacy single-fetch fields for existing Kehto callers.import {
createIdentityService,
createListsService,
createNotificationService,
createBleService,
createSerialService,
createWebrtcService,
} from '@kehto/services';
// Identity service — read-only lookups backed by a signer adapter.
runtime.registerService(
'identity',
createIdentityService({
getSigner: () => signer,
getProfile: (pk) => nostrClient.fetchProfile(pk),
getFollows: (pk) => contactListCache.getFollows(pk),
}),
);
// Notification service — legacy inc-emit channel, browser badge fan-out.
runtime.registerService(
'notifications',
createNotificationService({ onChange: (list) => updateBadge(list) }),
);
// Lists service — shell-owned NIP-51 metadata and mutations.
runtime.registerService(
'lists',
createListsService({
supported: () => [{ kind: 10003, type: 'bookmarks', addressable: false }],
add: (_list, items) => ({ ok: true, added: items.length }),
remove: (_list, items) => ({ ok: true, removed: items.length }),
}),
);
// Serial service — runtime-owned serial sessions and host-owned device access.
runtime.registerService(
'serial',
createSerialService({
open: () => ({ session: { id: 'host-session-1', state: 'open' } }),
write: (_sessionId, _data) => {},
close: (_sessionId) => {},
}),
);
// BLE service — runtime-owned BLE/GATT sessions and host-owned device access.
runtime.registerService(
'ble',
createBleService({
open: () => ({
session: {
id: 'host-ble-1',
state: 'open',
device: { id: 'host-device-1', name: 'Host BLE' },
},
}),
services: () => [],
read: () => [87],
write: (_sessionId, _target, _data) => {},
subscribe: (sessionId, target, ctx) => {
ctx.emit({ type: 'notification', sessionId, target, data: [87] });
},
unsubscribe: (_sessionId, _target) => {},
close: (_sessionId) => {},
}),
);
// WebRTC service — runtime-owned sessions and host-owned signaling/transport.
runtime.registerService(
'webrtc',
createWebrtcService({
open: (request, ctx) => {
const id = 'host-webrtc-1';
ctx.emit({ type: 'state', sessionId: id, state: 'open' });
return {
session: {
id,
scope: request.scope,
channel: request.channel ?? 'default',
protocol: request.protocol,
state: 'open',
},
};
},
send: (sessionId, payload, ctx) => {
ctx.emit({ type: 'message', sessionId, from: 'host', payload });
},
close: (_sessionId) => {},
}),
);
Reference keyboard / chord backend for the keys.* NIP-5D NAP. By default attaches a single document-level keydown listener that matches incoming events against registered chord subscriptions and delivers a keys.action push envelope back to the owning napplet. Implement the HostKeysBridge interface to swap in OS-level backends (Electron globalShortcut, Tauri GlobalShortcut).
import { createKeysService } from '@kehto/services';
export function createKeysService(options?: KeysServiceOptions): ServiceHandler & { destroy(): void };
destroy() detaches the document listener (or the bridge's unsubscribe handles) and clears all subscription registries. Call on shell teardown.
| Field | Type | Description |
|---|---|---|
onForward |
(event: { key, code, ctrlKey, altKey, shiftKey, metaKey }) => void |
Called on keys.forward envelopes AND on matching document keydowns. DOM-shape payload (the service translates from the wire-format { ctrl, alt, shift, meta } before invoking this callback). |
listenerTarget |
EventTarget |
Defaults to document. Pass a fresh new EventTarget() in unit tests to isolate the listener. Ignored when hostBridge is provided. |
hostBridge |
HostKeysBridge |
Pluggable OS-bridge. When provided, the service delegates keys.registerAction to bridge.subscribe(chord, cb) and the default document listener is NOT attached. |
reservedChords |
ReadonlyArray<string> |
Optional set of shell-reserved chords (wire-format strings like 'Ctrl+Shift+K', 'Cmd+P'). When a napplet forwards a reserved chord via keys.forward OR a document keydown matches a reserved chord, onForward (or the hostBridge handler) fires but keys.action is NOT dispatched to any napplet that registered the same chord via keys.registerAction. Precedence: reserved > registered. Normalized once at construction via the same parser used for action.defaultKey. See Reserved Chords. |
Copy the contract verbatim for host-app implementers. OS-level bridges implement subscribe at minimum; the two optional fields enable global-hotkey registration (works even when the host window is unfocused).
export interface HostKeysBridge {
/**
* Subscribe a callback to a chord. Returns an unsubscribe handle.
*
* Implementations MUST:
* - invoke `callback` exactly once per matching chord event (implementations
* are responsible for any OS-autorepeat filtering)
* - invoke `callback` synchronously during the event delivery
* - accept the string chord format documented by @napplet/nap/keys
* (e.g. `'Ctrl+Shift+K'`, `'Cmd+P'`)
*/
subscribe(chord: string, callback: (event: KeyboardEvent | HostKeyEvent) => void): () => void;
/**
* Optional: register an OS-level global hotkey (works even when the host
* window is not focused). Returns true on success, false if the chord
* cannot be registered (e.g. already claimed by another app).
*
* Omitted by the browser reference implementation — browsers cannot
* register OS-level global hotkeys without privileged APIs. Electron
* (`globalShortcut`) and Tauri (`GlobalShortcut`) provide this.
*/
registerGlobalHotkey?(chord: string): boolean;
/**
* Optional: subscribe to OS-level global hotkey events (regardless of
* focus). Returns an unsubscribe handle.
*
* Omitted by the browser reference implementation. See
* {@link HostKeysBridge.registerGlobalHotkey}.
*/
onGlobalHotkey?(callback: (chord: string) => void): () => void;
}
Default browser path — the reference document-level chord listener:
import { createKeysService } from '@kehto/services';
const keys = createKeysService({
onForward: (event) => {
// DOM-shape payload: { key, code, ctrlKey, altKey, shiftKey, metaKey }
hotkeyDispatcher.dispatch(event);
},
});
runtime.registerService('keys', keys);
// On shell teardown:
keys.destroy();
Custom bridge path — swap in Electron's globalShortcut:
import { createKeysService, type HostKeysBridge } from '@kehto/services';
import { globalShortcut } from 'electron';
const electronBridge: HostKeysBridge = {
subscribe(chord, cb) {
globalShortcut.register(chord, () => cb({
key: '', code: '',
ctrlKey: false, altKey: false, shiftKey: false, metaKey: false,
} as KeyboardEvent));
return () => globalShortcut.unregister(chord);
},
};
runtime.registerService('keys', createKeysService({ hostBridge: electronBridge }));
Plug a HostKeysBridge when the default document listener is insufficient: Electron or Tauri apps that need to register OS-level global hotkeys (chords delivered even when the host window is not focused), native shells that route chords through a platform-specific hotkey manager (macOS Carbon, Linux X11 grab, Windows RegisterHotKey), or test harnesses that inject synthetic events through a controlled EventTarget. The bridge owns subscription lifecycle; the service retains per-window bookkeeping (so onWindowDestroyed cleanup stays identical across paths).
A napplet drives this end to end via @napplet/sdk — keys.registerAction to claim a chord and keys.onAction to receive dispatches against the real backend.
Shell-reserved chords let a host application (window manager, launcher shell, tiling WM) claim specific chords for its own dispatch regardless of what napplets subscribe to. Declare the reserved set once at service construction via the reservedChords option on KeysServiceOptions:
import { createKeysService } from '@kehto/services';
const keys = createKeysService({
reservedChords: [
'Ctrl+Alt+T', // launcher
'Super+Space', // workspace switch
'Ctrl+Shift+Q', // window close
],
onForward: (event) => {
// The shell's WM dispatcher — fires for reserved chords regardless of
// which napplet (if any) tried to register them.
wmLauncher.dispatch(event);
},
});
runtime.registerService('keys', keys);
Precedence contract: reserved > registered. When a napplet forwards a chord via keys.forward — or when the default document keydown listener matches a chord registered by a napplet via keys.registerAction — the service consults the reserved set first:
onForward (or the hostBridge-registered handler) fires exactly once. No keys.action envelope is dispatched to any napplet, even if the napplet registered the identical chord. This is intentional — the shell WANTS the forward; that is why it reserved the chord.onForward fires AND every napplet whose registered action matches receives a keys.action envelope via its captured send handle.Reserved chords are normalized at service construction via the same parser used for action.defaultKey, so 'Ctrl+Shift+K', 'Control+shift+k', and 'ctrl+Shift+K' all match the same chord. Modifier aliases (Cmd / Command / Win / Super → meta; Control → ctrl; Option → alt) are recognized case-insensitively.
WM-launcher integration example:
// Shell-side: declare every WM-absolute chord at boot.
const keys = createKeysService({
reservedChords: Object.keys(wmChordMap), // e.g. ['Super+1', 'Super+2', ..., 'Ctrl+Alt+T']
onForward: (event) => {
const chordStr = chordStringFromEvent(event);
const action = wmChordMap[chordStr];
if (action) action.execute();
},
});
runtime.registerService('keys', keys);
// Napplet-side (hotkey-chord napplet, for example): perfectly free to register
// `Ctrl+Shift+K` via keys.registerAction. If Ctrl+Shift+K is NOT in the shell's
// reservedChords, the napplet receives keys.action as normal. If a shell later
// adds Ctrl+Shift+K to its reserved set (e.g. because it now binds the chord
// to a WM action), the napplet's registration is silently suppressed for that
// chord — the shell is authoritative.
Dynamic reservation is out of scope for v1.6. If a downstream shell needs runtime updates to the reserved set (e.g. "reservation depends on which workspace is active"), open an issue referencing HostKeysBridge.reserveAbsolute(chords) — the deferred extension shape. Until then, reservedChords is static at service construction.
OS-level global hotkeys remain a separate concern. reservedChords operates at the service layer — the chord must still reach the host window's focus (or be forwarded via keys.forward). For OS-level reservation (chord fires even when the host window is unfocused), implement HostKeysBridge.registerGlobalHotkey in your bridge — reserved chords and global hotkeys compose orthogonally.
Reference media backend for the media.* NIP-5D NAP. By default mirrors session metadata + playback state to navigator.mediaSession via the DOM MediaSession API and installs setActionHandler callbacks that emit media.command push envelopes on OS transport events (play / pause / next / previous / seek). Implement the HostMediaBridge interface to swap in native backends (Electron bridge, MPRIS on Linux, MediaRemote on macOS).
import { createMediaService } from '@kehto/services';
export function createMediaService(options?: MediaServiceOptions): ServiceHandler & { destroy(): void };
destroy() tears down the active bridge (removes setActionHandler listeners, removes the silent-audio prime element in the browser reference implementation) and clears the session registry.
| Field | Type | Description |
|---|---|---|
onSessionCreate |
(windowId, sessionId, metadata?) => void |
Called when a napplet creates a session. |
onState |
(windowId, sessionId, state) => void |
Called on media.state updates — high-frequency; keep handler work minimal. |
onSessionDestroy |
(windowId, sessionId) => void |
Called when a napplet destroys a session. |
onSessionUpdate |
(windowId, sessionId, metadata) => void |
Called when a napplet updates session metadata. |
onCapabilities |
(windowId, sessionId, actions) => void |
Called when a napplet declares capabilities for a session. |
mediaSessionTarget |
MediaSessionTarget |
Overrides navigator.mediaSession (used by the default bridge only). Pass a MockMediaSession in unit tests. Ignored when hostBridge is provided. |
documentTarget |
Document | null |
Overrides document (used by the default bridge only). Set to null to disable the silent-audio prime in unit tests. Ignored when hostBridge is provided. |
hostBridge |
HostMediaBridge |
Pluggable backend. When provided, the service delegates setMetadata / setPlaybackState / onAction to the bridge and skips navigator.mediaSession entirely. |
Copy the contract verbatim for host-app implementers. Native bridges implement setMetadata + setPlaybackState + onAction at minimum; the two optional fields cover active-session switching and per-session teardown.
export interface HostMediaBridge {
/**
* Set the metadata displayed on the OS transport surface for a session.
* Called on session.create (with initial metadata) and on session.update
* (with merged metadata) whenever the session is the active session.
* Implementations MUST be idempotent.
*/
setMetadata(sessionId: string, metadata: MediaMetadata): void;
/**
* Set the playback state for a session. Called on media.state reports
* whenever the session is the active session. State strings match
* nap-media MediaState.status exactly. Implementations MUST be idempotent.
*/
setPlaybackState(sessionId: string, state: 'playing' | 'paused' | 'stopped' | 'buffering'): void;
/**
* Subscribe to OS-level action events (user clicks play/pause/seek/next/prev
* on the transport surface). Returns an unsubscribe handle.
*
* The callback receives `(sessionId, action, value?)`. `sessionId` is the
* bridge's currently-active session (the browser impl tracks this internally
* via setActionHandler-at-fire-time; native impls track via setActiveSession).
* `value` is populated for `action === 'seek'` (seek target in seconds) and
* for `action === 'volume'` (0.0-1.0). The service dispatches the resulting
* `media.command` envelope to the owning napplet of that session.
*/
onAction(callback: (sessionId: string, action: MediaAction, value?: number) => void): () => void;
/**
* Optional: notify the bridge that the active session has changed. The
* browser reference impl uses this to switch which session's metadata/state
* is mirrored to the singleton navigator.mediaSession and to install (or
* clear) action handlers for the session's declared capabilities.
*
* The optional `actions` parameter carries the session's declared capability
* set so the bridge can narrow which OS transport buttons are active. When
* omitted, the bridge applies its default set. Native OS bridges that track
* active-session state internally may omit this field entirely.
*/
setActiveSession?(sessionId: string | null, actions?: readonly MediaAction[]): void;
/**
* Optional: tear down per-session resources. The browser reference impl
* uses this to remove the silent-audio prime element when the last session
* is destroyed. Bridges that need no per-session teardown may omit this field.
*/
destroySession?(sessionId: string): void;
}
Default browser path — the reference navigator.mediaSession mirror:
import { createMediaService } from '@kehto/services';
const media = createMediaService({
onSessionCreate: (windowId, sessionId, metadata) => {
console.log(`[${windowId}] created session ${sessionId}`, metadata);
},
onState: (windowId, sessionId, state) => {
nowPlaying.update(windowId, state);
},
});
runtime.registerService('media', media);
// On shell teardown:
media.destroy();
Custom bridge path — swap in an Electron host bridge:
import { createMediaService, type HostMediaBridge, type MediaAction } from '@kehto/services';
import { mediaBridge } from './electron-media-bridge';
const electronBridge: HostMediaBridge = {
setMetadata(sessionId, md) {
mediaBridge.sendMetadata({ sessionId, md });
},
setPlaybackState(sessionId, state) {
mediaBridge.sendPlaybackState({ sessionId, state });
},
onAction(cb) {
const handler = (_: unknown, msg: { sessionId: string; action: MediaAction; value?: number }) =>
cb(msg.sessionId, msg.action, msg.value);
mediaBridge.onAction(handler);
return () => mediaBridge.offAction(handler);
},
};
runtime.registerService('media', createMediaService({ hostBridge: electronBridge }));
Plug a HostMediaBridge when navigator.mediaSession is insufficient: Electron apps that need to route transport events through the main process (lock-screen integration on Windows, Now Playing integration on macOS), Linux shells that speak MPRIS over D-Bus, native mobile wrappers that forward to AVPlayer / ExoPlayer, or test harnesses that record action events without touching the DOM. The bridge owns metadata/state mirroring and OS action routing; the service retains per-session bookkeeping (sessionRegistry + per-window send handles) so media.command dispatch semantics stay identical across paths.
A napplet drives this end to end via @napplet/nap/media — mediaCreateSession({ owner: 'napplet', ... }), mediaReportState, and mediaOnCommand against the real backend.
Each factory returns a ServiceHandler registrable via runtime.registerService(). The bullets below note the current NIP-5D domain the handler owns and the ACL capability napplets need in order to reach it.
createIdentityService — identity.* reads (identity:read). No signing surface; shell mediates signing internally.createIdentityService uses getSigner() for identity.getPublicKey and identity.getRelays. Hosts may also pass optional read-only provider hooks for getProfile, getFollows, getList, getZaps, getMutes, getBlocked, and getBadges. These hooks receive the current signer pubkey (or "" when no signer is connected) and return the payload portion of the corresponding .result envelope. Kehto does not query relays itself; the hooks are for hosts that already maintain profile, contact-list, list, zap, moderation, or badge data.
createNotifyService — canonical notify.* envelopes (notify:send / notify:channel).createNotificationService — legacy inc-emit notifications:* channel; coexists with createNotifyService until retired.createRelayPoolService — relay.publish, relay.publishEncrypted, relay.subscribe fan-out (relay:read / relay:write).createCacheService — offline event cache (cache:read / cache:write).createCoordinatedRelay — composite service that bundles relay-pool + cache with read-through behavior.createKeysService — keys.registerAction / keys.unregisterAction / keys.forward + keys.action push envelopes (keys:forward). Document-level chord listener by default; implement the HostKeysBridge interface to swap in Electron / Tauri / OS-level backends. See Keys Service for the full contract.createMediaService — owner-aware media.session.create / update / destroy / media.state / media.capabilities + media.command push envelopes (media:control). Napplet-owned sessions mirror to navigator.mediaSession by default; shell-owned creates are rejected until a host playback/fetch bridge is supplied. Implement the HostMediaBridge interface to swap in native backends. See Media Service for the full contract.createBleService — ble.open, ble.services, ble.read, ble.write, ble.subscribe, ble.unsubscribe, ble.close + host-pushed ble.event envelopes. Hook contexts include ctx.emit(event), so host Bluetooth notification listeners can forward notification, state, and closed events to the requesting napplet without replacing the reference handler.createWebrtcService — webrtc.open, webrtc.send, webrtc.close + host-pushed webrtc.event envelopes. The reference handler owns only the NAP request/result bookkeeping; host bridges own signaling, SDP, ICE, peer connection lifecycle, and policy.createThemeService — theme.get + theme.changed fan-out (theme:read). Returns a ThemeService with publishTheme() / setTheme() utilities for host-side updates.createAudioService — audio:* inc-emit topic handler. Browser-agnostic registry of per-window audio sources; host wires onChange to update transport UI.AudioSource, AudioServiceOptions, Notification, NotificationServiceOptions, IdentityServiceOptions, RelayPoolServiceOptions, CacheServiceOptions, CoordinatedRelayOptions, KeysServiceOptions, MediaServiceOptions, NotifyServiceOptions, ThemeServiceOptions, ThemeService, BleServiceOptions, BleServiceContext, WebrtcServiceOptions, WebrtcServiceContext.
Full package docs: docs/packages/services.md.
Generated API module: docs/api/modules/_kehto_services.html (run pnpm docs:api).
MIT