Skip to content

Architecture ​

kernl is three layers. kehto supplies the primitives, four kernl packages supply the opinions, and one app renders them. Nothing in a lower layer knows about a higher one.

┌──────────────────────────────────────────────────────────────────────┐
│  apps/web  (@kernl/web)                        Astro 7 + Svelte 5    │
│  landing site at /         shell at /app/      docs at /docs/        │
├──────────────────────────────────────────────────────────────────────┤
│  @kernl/core                                   composition root      │
│  createKernl · applesauce adapters · decodePointer → resolve →       │
│  plan → mount · guard pipeline · grants · describeChange · layouts   │
│       │                 │                   │                        │
│  @kernl/guard      @kernl/messaging     @kernl/bridges               │
│  ingress, gate,    provider contract,   locators, Bridge contract,   │
│  focus firewall,   registry, composite  registry, HTTP bridge        │
│  CSP, profiles     dm adapter           with SSRF policy             │
├──────────────────────────────────────────────────────────────────────┤
│  kehto   @kehto/shell · runtime · acl · firewall · services · nip ·  │
│          wm                                                          │
│  applesauce   EventStore · RelayPool · AccountManager · signers ·    │
│               loaders                                                │
├──────────────────────────────────────────────────────────────────────┤
│  @napplet/core   NAP wire types, NapDomain, envelope dispatch        │
└──────────────────────────────────────────────────────────────────────┘

The kehto layer ​

kehto is the runtime authority for how a NIP-5D shell composes. kernl uses these seams unchanged:

kehto packageWhat kernl takes from it
@kehto/shellcreateShellBridge, buildShellCapabilities, resolveShellEnvironment, prepareNappletSrcdoc, denyNappletControllerAccess, renderNappletFramePolicyMeta, originRegistry, the ShellAdapter hooks and the revocation model.
@kehto/runtimeThe protocol engine behind the bridge: dispatch, the ACL gate (bridge.runtime.aclState, ALL_CAPABILITIES), the runtime firewall (bridge.runtime.firewallState), the ServiceRegistry and ConsentRequest.
@kehto/aclThe grant store the runtime enforces, keyed by dTag:hash. kernl reaches it only through bridge.runtime.aclState.
@kehto/firewallThe pure evaluate() engine @kernl/guard runs, and the NappletRules and RateLimit types runtimeFirewallRules translates into.
@kehto/servicescreateRelayPoolService, createIdentityService, createThemeService, createNotifyService, createLinkService, createResourceService, createDmService, createOutboxService and createRelayPoolOutboxRouter.
@kehto/nipresolveNapplet, openNappletArtifactCache and NAPPLET_KINDS from @kehto/nip/5d.
@kehto/wmLayoutStrategy, WindowState, Rect and WindowPlacement, which the layouts implement.

kernl never re-implements a kehto primitive. When a seam is too narrow, kernl composes in front of it and takes the gap upstream; the four gaps found so far, filed upstream as kehto/web #397–#400, are in Alpha status.

One event layer ​

Every relay connection, event store, signer, account and loader goes through applesauce v7 (D-002). createKernl creates exactly one of each per shell, or uses the one the host passes in:

InstanceRoleAdapted by
EventStoreThe in-memory, reactive store of every event the shell knows. Applets never receive a reference; createIdentityProviders (applesauce/identity-providers.ts) and lookupReplaceable read from it on their behalf.kehto's identity service and the consent handler.
RelayPoolEvery relay connection.createRelayPoolHooks and createRelayPoolLike (applesauce/relay-hooks.ts) for ShellAdapter.relayPool; createAppletRelayBackend, createDmRelayPool and createOutboxRelayPool (applesauce/relay-backend.ts) for the relay, dm and outbox services. All of them route through selectRelayTier.
AccountManagerThe signed-in accounts and their signers.createKernlAccounts (applesauce/accounts.ts) refuses plaintext-key account types; createAuthHooks and wrapAccountSigner (applesauce/auth-hooks.ts) give kehto a signer that never reveals the account.

nostr-tools is a peer dependency of @kernl/core, because kehto and applesauce require it. kernl source never imports it, and scripts/check-dependency-rules.mjs fails the build if it does.

The kernl packages ​

PackageDepends onPurpose
@kernl/guard@kehto/firewallFlood control, visibility gate, focus-aware firewall, CSP profile, mitigation descriptions. Pure: no DOM, no timers, time is injected.
@kernl/messaging@napplet/core (types)The provider contract and the composite backend. Structurally identical to kehto's DmAdapter so a composite can be handed to createDmService without translation, while the package stays free of a kehto dependency.
@kernl/bridgesnothingLocator parsing, the Bridge contract, the registry with its authorize hook, the HTTP bridge.
@kernl/coreall of the above, kehto, applesauceComposition: adapters, the applet lifecycle, the guard pipeline and runtime firewall rules, grants, consent summaries, layouts, the namespaced store. Browser-agnostic; the DOM is reached through KernlHost and FrameLike.

The three leaf packages have zero framework dependencies and import nothing from each other. Only core composes them. The dependency rules are enforced by pnpm check:deps.

Where each NAP domain is served from ​

An applet reaches the shell through the NAP domains in window.napplet. createKernlServices (kernl-services.ts) registers eight kehto services; shell, inc, storage, keys and media are routed by kehto's runtime itself. What one applet sees is buildShellCapabilities(adapter).domains narrowed by createEnvironmentResolver(grants).

NAP domainServed bykernl suppliesGuarded by
shell, inckehto runtimethe environment from resolveShellEnvironment, narrowed per identityidentity binding; baseline, no grant needed
storagekehto runtimethe ACL state quota (grants.setStateQuota)baseline; rate storage:write
identitycreateIdentityServicethe active signer through createAuthHooks; profile, follows, mutes and relays from createIdentityProvidersgrant (identity:read), "your account" in the shell
themecreateThemeServiceKernlOptions.theme when isTheme accepts itbaseline
relaycreateRelayPoolServicecreateAppletRelayBackend over the one pool, routed by selectRelayTiergrant; rates relay:subscribe, relay:publish; subscribe envelope cap
outboxcreateOutboxServicecreateRelayPoolOutboxRouter over createOutboxRelayPool, relay lists from createIdentityProviders, signing through the active signer (denied:read-only without one)grant; rates outbox:subscribe, outbox:publish; consent for destructive kinds
resourcecreateResourceServicecreateBridgeResourceResolver(bridges) with the requesting applet named through resourceInitForgrant; rate resource:bytes, resource envelope cap, hidden resource allowance, SSRF policy
dmcreateDmServicemessaging.composite()grant; rate dm:send; provider availability
notifycreateNotifyServicehost.notify with tone infogrant
linkcreateLinkServicehost.openLink after LINK_PROTOCOLS (https:, mailto:) allowed itgrant
keys, mediakehto runtimenothing: hotkeys are not forwarded and no media hook is wiredgrant

So the shell offers thirteen domains: the baseline shell, inc, theme and storage, and identity, relay, outbox, resource, dm, notify, link, keys and media behind a grant. Domains kernl does not serve (config, cvm, upload, intent, fs, llm, cache) have a row in DOMAIN_CAPABILITIES so a grant can be expressed, but the shell does not offer them and grants.allowDomain refuses them with invalid:domain. Identity and grants explains how the set is narrowed.

The mount path ​

createAppletLifecycle (kernl-lifecycle.ts) turns an address into a running window in four steps, and the order is load-bearing:

  1. decodePointer(input) (applets/pointer.ts) turns the address into { kind: 'address' | 'event', … } and nothing more. Trust comes from verifying the event, never from the pointer.
  2. resolve(pointer) fetches the manifest from the pointer's relay hints plus the apps tier (fetchManifestEvent), then verifies the signature and the artifact bytes by hash through resolveApplet over kehto's resolveNapplet (applets/resolve.ts). Cached bytes are re-hashed on every read. Gateways and caches are accelerators, never identity.
  3. plan(summary) calls grants.ensure(identity), then planMount (applets/plan.ts): it refuses markup that declares a frame (denied:nested-frame), then builds the environment from resolveShellEnvironment, the guard's CSP and the namespace prelude inserted by prepareNappletSrcdoc, the Trusted Types directives and frame lock spliced in after them (D-014), sandbox: 'allow-scripts', and missingRequired.
  4. mount(plan, frame) installs the profile's rates in kehto's runtime firewall with applyRuntimeFirewallRules, then mountApplet (applets/mount.ts) hardens the frame, registers the window in originRegistry, sets the environment, calls bridge.observeFrame, and only then assigns srcdoc. The frame has sandbox="allow-scripts" and denyNappletControllerAccess applied; it never has src and never allow-same-origin.

Every revocation reason (window-message, second-hello, invalid-hello, second-load, port-closed, host) and a guard quarantine removes the iframe and drops the guard's state for that window. Security posture lists the rules this path must keep.

The app ​

apps/web is Astro 7 with Svelte 5 islands. It owns the landing site at /, the shell at /app/, and the KernlHost that gives @kernl/core a page: localStorage, the consent dialog, notices, link confirmation and window visibility. It creates the iframes, wires window.addEventListener('message', kernl.handleWindowMessage) once, and renders the launcher, the grants panel per window, the guard panel and the provider picker. The published packages contain none of this; the app is the only framework-dependent workspace (D-004). ?preview=1 swaps in preview-kernl.ts, which implements the same Kernl contract without a network (D-011).

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