Appearance
Relay tiers
The shell, not the applet, decides which relays a request goes to. It sorts its relays into four tiers, routes each request to one tier by the kinds it asks for, and lets the user edit every tier in Settings. Applets never name a relay for an ordinary query or publish; the one exception is a scoped relay a window opens for itself, described at the end of this page.
The four tiers
kehto's RelayConfigHooks knows three tiers: discovery, super and outbox. kernl adds a fourth, apps, for applet manifests. The RelayTier type is the union of the four names.
| Tier | Settings label | What it serves | Default relays (DEFAULT_RELAY_TIERS) |
|---|---|---|---|
discovery | Discovery | Profiles, follow lists and relay lists (kinds 0, 3, 10002) | wss://purplepag.es, wss://relay.nos.social |
apps | Applet catalogue | Applet manifests (kehto's NAPPLET_KINDS, 5129, 15129 and 35129, plus the legacy catalogue kind 32267) | wss://relay.napplet.soy, wss://relay.ditto.pub, wss://relay.nostr.com (the catalogues the public napplet stores read; with super they hold every napplet either lists) |
outbox | Your connections | Where what the user publishes is delivered | none |
super | General | Everything that is not discovery or manifests | wss://relay.damus.io, wss://nos.lol, wss://relay.primal.net |
DEFAULT_RELAY_TIERS is exported from @kernlize/core as a frozen TierMap, each tier a frozen array. The outbox tier starts empty: kernl does not guess where the user publishes.
How a request picks a tier
selectRelayTier(tiers, filters) in packages/core/src/applesauce/relay-hooks.ts collects the kinds of every filter and chooses:
- If any filter has no
kinds, or an emptykinds, the request cannot be classified and takes the general path. - If every kind is
0,3or10002, the request goes to thediscoverytier together withoutboxandsuper: indexers hold most profiles and lists, and NIP-65 puts them on their authors' own servers too. - If every kind is an applet-manifest kind, the request goes to the
appstier. - Otherwise it goes to
superandoutboxtogether.
The result is deduplicated. A request that mixes a profile kind and a note kind is not split; it takes the general path as a whole.
ts
selectRelayTier(tiers, [{ kinds: [0], authors: [pubkey] }]); // discovery, outbox and super
selectRelayTier(tiers, [{ kinds: [35129] }]); // tiers.get().apps
selectRelayTier(tiers, [{ kinds: [1] }]); // super, then outbox
selectRelayTier(tiers, [{ ids: [id] }]); // super, then outbox: no kindsThe same function is used everywhere the shell picks relays on its own:
- kehto's relay service asks
RelayPoolHooks.selectRelayTier, whichcreateRelayPoolHooksanswers with this function. - kernl's applesauce relay backend (
relay-backend.ts) uses it for a subscription or query that names no relays, and for every publish, by the published event's kind. A published profile therefore goes to the discovery tier and the user's own and general servers, and a published note tosuperplusoutbox. A publish succeeds when any relay accepts it; when none does it fails withunavailable:not-accepted:0/<n>(D-028). - Manifest lookups (
fetchManifestEvent) ask the relays named in the applet's address plus the wholeappstier, and fall back to thesupertier when those do not produce the manifest. One relay failing does not fail the lookup, and a relay that answers quickly with nothing does not end a round before a slower one has hadMANIFEST_TIMING.patienceMsto answer (D-025). - The destructive-write consent lookup, which fetches the current version of a replaceable event before the user is asked, asks
outboxplusdiscovery.
Where the configuration lives
createRelayTiers(storage, defaults?) builds the configuration and returns a RelayTiers: get(), add(tier, url), remove(tier, url) and changes$. createKernl calls it with host.storage and KernlOptions.relays, and exposes it as kernl.relays.
- Persistence key. The whole map is written as JSON to
kernl:relays:v1(the keyrelays:v1in acreateNamespacedStorewith prefixkernl) on every change. See Persistence. - Start-up. When
kernl:relays:v1holds a value, each tier it names replaces the default for that tier and any tier it lacks keepsDEFAULT_RELAY_TIERS. The host'sKernlOptions.relaysis applied over the defaults only when nothing is persisted, so once the user has edited a tier, the host's overrides no longer apply on that device. A corrupt value reads as absent. - Validation.
addaccepts onlyws://orwss://followed by non-space characters and throwsinvalid:relay-urlfor anything else. Adding a URL a tier already holds does nothing.removeof a URL the tier does not hold does nothing. - Observation.
changes$is an RxJSBehaviorSubject, so a new subscriber receives the current map at once. Every value it emits is frozen.
How Settings edits them
The shell's Settings panel (apps/web/src/shell/SettingsPanel.svelte) has a Network section with one block per tier, labelled as in the table above. It subscribes to kernl.relays.changes$, lists each server by name with a Remove button that calls kernl.relays.remove, and has a "Server address" field that calls kernl.relays.add. A bare hostname is given the secure scheme before it is added (serverAddress in servers.ts), so typing relay.example stores wss://relay.example. When add throws an invalid: reason the panel asks for a server address; any other failure says the server could not be added.
The panel never shows the word relay or a protocol scheme to the user (Public vocabulary). A change takes effect for the next request; subscriptions already open keep the relays they were opened with.
The offline preview keeps its own tier state at kernl:preview:relays:v1, so editing tiers in the preview never changes the live shell's.
Scoped relays
kehto lets a window open one relay of its own choosing, the NIP-29 group pattern. createRelayPoolHooks keeps this minimal: one open subscription per window, replaced when the window opens another, closed with the window, and events delivered to that window's port only. Publishing to the scoped relay goes to that one relay. Tiers do not apply to scoped relays.