@kehto/services
v0.20.0
Published
Reference service implementations for the napplet protocol — notifications and extensible service handlers
Maintainers
Readme
@kehto/services
Reference service handlers for the napplet protocol — identity, relay pool, cache, keys, media, notify, theme, link, common, lists, serial, BLE, WebRTC, and DM.
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.
Install
pnpm add @kehto/servicesPublished Napplet Compatibility
@kehto/services publishes against @napplet/core and @napplet/nap
>=0.31.0 <0.32.0. The exact installed contracts are core 0.31.1 / nap 0.31.2
from NAP-INTENT authority 5ac0490461ca6fec2f0d2e45b4835cf9bc08de24,
napplet/web#199 source 3037200c932488f14f7f369b8583c39c9c16510a / merge
b3f0007867eac109fa4917fac9c285d3b7cc6155, and Version Packages #198 release
source dc1d24153c759152b6ba31a6ec9bea967798f2df.
Overview
@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 by the exact message.type domain (for
example, notify.create goes to the handler registered under notify).
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.
Services are selected only by the exact message.type domain. INC topics are
opaque, queryless identities matched only by exact equality, so their text must
not select a service handler. The runtime attaches the sender to delivered INC
events from the authenticated endpoint; services do not create INC events.
Current draft posture:
- The v1.1 signer service is deleted outright. Its responsibilities split into two: read-only identity lookups go through
createIdentityService(getPublicKey,getRelays,getProfile,getFollows,getList,getZaps,getMutes,getBlocked,getBadges); signing happens inside the shell as part ofrelay.publish/relay.publishEncryptedand is never exposed to napplets. createKeysServiceandcreateMediaServiceship real reference backends as of v1.4 (see the dedicated sections below).createKeysServiceattaches a document-levelkeydownlistener by default and deliverskeys.actionpush envelopes to registered napplets;createMediaServicemirrors session metadata and playback state tonavigator.mediaSessionand emitsmedia.commandpush 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.createNotifyServicehandles directnotify.*envelopes. It is not an INC topic handler.createOutboxServicesupportsoutbox.getEventfrom draft NAP-OUTBOX. Single-event lookups run through shell-owned relay routing and only return events whose ID matches the request. The draft wire contract keepsoutbox.queryone-shot andoutbox.subscribestreaming; Kehto's concretecreateRelayPoolOutboxRouteradditionally exposes host-sidequeryStream()so verified query events can arrive before asynchronous NIP-65 discovery completes.createResourceServiceimplements the draft NAP-RESOURCE request lifecycle and current result shapes. It fail-closes when no scheme is configured, checks per-identity origin grants, enforces disclosed size/bulk caps, scopes cancellation to the requesting window, drops cancelled terminal envelopes, and never forwards response headers. The host policy fetch must return byte-classified output and owns scheme-specific SSRF, redirect, integrity, and SVG handling.createUploadServicesupportsupload.infofrom draft NAP-UPLOAD. Hosts may expose configured rails, return URL forms, maximum bytes, and MIME type policy without requiring napplets to start an upload.createConfigServiceimplements draft NAP-CONFIG's shell-writer boundary: recursive bounded-subset schema validation, per-window schema/subscription state, deterministic defaults, invalid/orphan removal, version rollback rejection, scoped host persistence hooks, and settings-UI commits. Reads before schema registration fail closed withno-schema.createDmServicekeeps NAP-DM request correlation, per-window subscriptions, and packaged message shapes in runtime-owned code. Adapters cover concrete transports: verified NIP-17 gift wraps and relay history vianostr-tools, structural NDR runtimes with relay hooks, and Cordn/ContextVM coordinator clients.createFsServicekeeps NAP-FS result correlation and per-window watch identity in runtime-owned code. An injectedFsBackendowns real persistence, virtual-path policy, picker mediation, byte validation, revisions, mutations, and watch observation.
NAP-INTENT manifest resolver
Kehto follows merged NAP-INTENT at
5ac0490461ca6fec2f0d2e45b4835cf9bc08de24.
Callers invoke a stable, queryless napplet:<archetype>/<action> convention.
Installed verified manifest tags produce exact { slug, convention }
declarations; numbered protocol names, trailing metadata, and payload inspection
do not select a handler.
manifestToIntentCatalogEntry()converts resolved manifest{ dTag, title?, archetypes: [{ slug, convention }] }data into exact candidates withactionsandconventions.createCatalogIntentResolver()filters by exact convention, applies the user-owned default/chooser/explicit-authorization policy, and asks anIntentTargetControllerto create/focus a target and dispatch the selected convention.createIntentService()validates source envelopes, uses the runtime-attested sender, returns one final canonicalIntentResult, and broadcasts catalog changes through recipient-policy-aware runtime sends.
ok: true means the selected target was ready and the convention was dispatched.
The result includes handled, handler, windowId, and convention. The
target receives the convention and opaque payload through one runtime-attested
inc.event; there is no separate intent.deliver lifecycle.
Paja currently exposes only an exact-contract development simulator, and the
playground currently exposes only a verified-manifest catalog builder. Phase
105 completed released @napplet/* package adoption plus the persistent live
catalog/controller and feed-to-profile flow. Its public Intent* types are
canonical releases from @napplet/core / @napplet/nap, not a local mirror;
successful results report completed target dispatch.
Quick Start
import {
createIdentityService,
createListsService,
createNotifyService,
createBleService,
createDmService,
createNip17DmAdapter,
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 — direct NIP-5D notify.* envelopes.
runtime.registerService(
'notify',
createNotifyService({
present: ({ notificationId, message, emit }) => notificationCenter.show({
notificationId,
title: message.title,
body: message.body,
onClick: () => emit({ type: 'notify.clicked', notificationId }),
}),
dismiss: (_windowId, notificationId) => notificationCenter.dismiss(notificationId),
requestPermission: (_windowId, channel) => notificationPolicy.request(channel),
}),
);
// 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(
'dm',
createDmService({
adapter: createNip17DmAdapter({
ownerSecretKey: shellOwnedSecretKey,
relayPool: shellRelayPool,
relays: ['wss://relay.example'],
}),
}),
);
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) => {},
}),
);Outbox Streaming Queries
NAP-OUTBOX is still an open upstream draft (napplet/naps PR #32). Its wire outbox.query.result remains one aggregate response. Hosts using Kehto's concrete router can also consume the same bounded read incrementally without changing the napplet wire protocol:
const stream = router.queryStream(
[{ authors: [author], kinds: [1] }],
{ timeoutMs: 3000 },
{ event: (result) => render(result.event) },
);
const aggregate = await stream.result;When relay-list loading is asynchronous, the router opens validated relay hints or fallback relays immediately, attaches discovered author write relays to the same deduplicating collector, and applies one deadline to discovery and collection. query() remains the compatibility aggregate over this stream. options.limit applies to that aggregate; the stream receives every verified unique arrival before completion. subscribe() uses the same immediate seed fanout before adding discovered relays.
Keys Service
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).
Factory
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.
KeysServiceOptions
| Field | Type | Description |
|-------|------|-------------|
| onForward | (event: { key, code, ctrlKey, altKey, shiftKey, metaKey }) => void | Called on keys.forward envelopes and on document/host-bridge keydowns handled by the reference backend. DOM-shape payload (the service translates from the wire-format { ctrl, alt, shift, meta } before invoking this callback). Forwarded keys do not dispatch keys.action; active napplets suppress bound keys locally from keys.bindings before forwarding. |
| 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'). Reserved chords are not assigned as napplet action bindings. Document keydowns that match a reserved chord call onForward but do not dispatch keys.action. Precedence: reserved > registered. Normalized once at construction via the same parser used for action.defaultKey. See Reserved Chords. |
HostKeysBridge interface
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;
}Usage
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 }));When to plug a custom bridge
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. After a successful bound registration, the service pushes a complete keys.bindings list for that napplet/window, using entries shaped as { actionId, key }. It pushes the complete list again after keys.unregisterAction removes a binding, including an empty list when no bindings remain. Injected shims use that list to suppress locally-bound keydowns before forwarding.
Reserved Chords
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+Y', // shell palette
],
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 > binding. When a napplet registers an action whose defaultKey matches reservedChords, the service acknowledges the action with keys.registerAction.result but leaves binding undefined and does not add the chord to keys.bindings. That prevents the active napplet from suppressing a shell-owned chord locally.
- If a document keydown matches a reserved chord:
onForwardfires exactly once and nokeys.actionenvelope is dispatched. - If a document keydown matches a non-reserved registered binding:
onForwardfires and every napplet whose registered action matches receives akeys.actionenvelope via its capturedsendhandle. - If a napplet sends
keys.forward:onForwardfires and the service does not dispatchkeys.action. Active napplets are expected to suppress bound keys locally fromkeys.bindingsbefore forwarding.
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): free to request
// `Ctrl+Shift+K` via keys.registerAction. If Ctrl+Shift+K is NOT in the shell's
// reservedChords, the napplet receives a binding and can handle it locally while
// active. If the shell reserves that chord, the registration result is unbound
// and no suppress-list entry is pushed.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. 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.
Media Service
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).
Factory
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.
MediaServiceOptions
| 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. |
HostMediaBridge interface
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;
}Usage
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 }));When to plug a custom bridge
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.
Public API
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.
Identity NAP
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.
Notify NAP
createNotifyService— canonical directnotify.*envelopes.
Relay NAP
createRelayPoolService—relay.publish,relay.publishEncrypted,relay.subscribefan-out (relay:read/relay:write). Publish handlers receive only runtime-signed events and settle with canonical{ ok, event, eventId }or{ ok: false, error }result envelopes. Subscribe adapters may pass observed relay URLs as the callback's second argument soRelayEventResult.sidecar.relayHintsrecords provenance rather than the larger requested relay set.createCacheService— offline event cache (cache:read/cache:write).createCoordinatedRelay— composite service that bundles relay-pool + cache with read-through behavior and the same canonical publish result.
Keys NAP
createKeysService—keys.registerAction/keys.unregisterAction/keys.forward+keys.bindings/keys.actionpush envelopes (keys:forward). Document-level chord listener by default; implement theHostKeysBridgeinterface to swap in Electron / Tauri / OS-level backends. See Keys Service for the full contract.
Media NAP
createMediaService— owner-awaremedia.session.create/update/destroy/media.state/media.capabilities+media.commandpush envelopes (media:control). Napplet-owned sessions mirror tonavigator.mediaSessionby default; shell-owned creates are rejected until a host playback/fetch bridge is supplied. Implement theHostMediaBridgeinterface to swap in native backends. See Media Service for the full contract.
BLE NAP
createBleService—ble.open,ble.services,ble.read,ble.write,ble.subscribe,ble.unsubscribe,ble.close+ host-pushedble.eventenvelopes. Hook contexts includectx.emit(event), so host Bluetooth notification listeners can forwardnotification,state, andclosedevents to the requesting napplet without replacing the reference handler.
WebRTC NAP
createWebrtcService—webrtc.open,webrtc.send,webrtc.close+ host-pushedwebrtc.eventenvelopes. The reference handler owns only the NAP request/result bookkeeping; host bridges own signaling, SDP, ICE, peer connection lifecycle, and policy.
DM NAP
createDmService—dm.status,dm.conversations,dm.messages,dm.send,dm.subscribe,dm.unsubscribe+dm.messagepush envelopes (dm:read/dm:write). Runtime owns correlation, subscriptions, cleanup, and normalized message shape; adapters own protocol transport.createNip17DmAdapter— concrete NIP-17 gift-wrap adapter backed bynostr-tools/nip17and an injected relay pool. The owner secret key stays shell/runtime-owned.createNdrDmAdapter/createNdrRelayTransport— structural adapter for Nostr Double Ratchet runtimes plus a relay transport bridge for app-ownednostrSubscribe/nostrFetch/nostrPublishhooks.createCordnDmAdapter/createCordnRelayCoordinatorClient— structural adapter for Cordn/ContextVM clients plus a relay-backed coordinator bridge forPostGroupMessage,FetchGroupMessages, andSubscribeGroupMessages.
FS NAP
createFsService— allfs.*request/result envelopes plus scopedfs.changedpushes. It guarantees same-id results, error/success-field exclusivity, cross-window watch isolation, and window-destroy cleanup.FsBackend— host contract for runtime-owned virtual paths, real picker/storage handles, bounded range I/O, atomic writes, directory mutation, and advisory watches.FsServiceErrormaps failures to the closed NAP-FS error set.
Theme NAP
createThemeService—theme.getplus automatictheme.changeddelivery (theme:read). Returns aThemeServicewithpublishTheme()andgetCurrentTheme()for host-side updates.
Intent NAP
createIntentService— exactintent.invoke,intent.available, andintent.handlersrequest/result handling plus recipient-gatedintent.changed.manifestToIntentCatalogEntry— verified manifest archetype contracts to catalog entries.createCatalogIntentResolver— exact convention selection and retained target-controller seam.
Identity and theme wire guarantees
Against NAP-IDENTITY/NAP-THEME at napplet/naps master
5ac0490461ca6fec2f0d2e45b4835cf9bc08de24,
createIdentityService is readonly: identity.getPublicKey always sends one
matching .result with pubkey: "" when a signer is absent or fails, and the
other supported reads retain their safe primary result fields. Unknown identity
actions are silent; identity changed values are host pushes, not request
retries or INC/intent traffic.
createThemeService accepts theme.get only and owns the current complete
three-color theme. publishTheme() normalizes and stores that state before it
invokes its one bridge callback, so an immediate theme.get observes the same
value as the automatic push. There is no theme subscribe/unsubscribe protocol.
For denied or unavailable runtime reads, Kehto uses the fixed non-sensitive
complete normal result without error: this explicit policy reconciles the
draft error-only example without a mixed theme/error extension.
Types
AudioSource, AudioServiceOptions, Notification, NotificationServiceOptions, IdentityServiceOptions, RelayPoolServiceOptions, CacheServiceOptions, CoordinatedRelayOptions, KeysServiceOptions, MediaServiceOptions, NotifyServiceOptions, NotifyPresentation, NotifyInteractionMessage, ThemeServiceOptions, ThemeService, IntentOpenOptions, IntentRequest, IntentResult, IntentCandidate, IntentAvailability, IntentResolver, IntentTargetController, IntentDispatchParams, IntentTargetDispatch, BleServiceOptions, BleServiceContext, WebrtcServiceOptions, WebrtcServiceContext, DmServiceOptions, DmAdapter, DmRelayPool, Nip17DmAdapterOptions, NdrDmAdapterOptions, CordnDmAdapterOptions.
API Reference
Full package docs: docs/packages/services.md.
Generated API module: docs/api/modules/_kehto_services.html (run pnpm docs:api).
License
MIT
