Files
2026-04-17 16:52:51 -04:00

20 KiB

Settings System — Kind 30078

This document is the definitive specification for how user settings and application-specific data are stored, encrypted, synced, and consumed across all projects in this ecosystem (client web pages, Didactyl agent, and future apps).

Table of Contents

  1. Overview
  2. NIP-78 Context
  3. User-Centric Layout
  4. Centralized User Settings Event
  5. Worker Lifecycle
  6. Page-Level API
  7. Standalone Kind 30078 Events
  8. Cross-Project Alignment: Didactyl
  9. Encryption Standard
  10. Adding a New Settings Namespace
  11. Audit Findings and Migration Plan
  12. Key Source Files

1. Overview

The app uses NIP-78 Application-specific Data (kind:30078) for two distinct purposes:

  1. Centralized User Settings — A single addressable event (d:user-settings) containing a namespaced JSON object. This is the user's portable preference file, designed to work across apps.
  2. Standalone Page Data — Individual addressable events with page-specific d tags for large or specialized data stores.

Both are parameterized replaceable events: for a given pubkey + kind + d-tag, only the latest event is retained by relays.

graph TD
    subgraph Centralized - d:user-settings
        W[SharedWorker ndk-worker.js]
        W -->|hydrate on init| C[IndexedDB cache]
        W -->|fetch + decrypt| R[Relays]
        W -->|NIP-44 encrypt + publish| R
        P1[Page A] -->|getUserSettings| W
        P2[Page B] -->|patchUserSettings| W
        P3[Page C] -->|onUserSettings| W
        D[Didactyl Agent] -->|reads llm from user prefs| R
    end

    subgraph Standalone - per-page d-tags
        S1[todo.html] -->|d:todo| R2[Relays]
        S2[cal.html] -->|d:calorie_foods / d:calorie_diary| R2
        S3[links.html] -->|d:links| R2
        S4[ai.html] -->|d:convo-id with t:client-ai-chat-v1| R2
        S5[keep-alive.html] -->|d:relay-list-N with t:relay-list| R2
        S6[post.html] -->|d:viewed| R2
    end

2. NIP-78 Context

NIP-78 specifies kind 30078 as an addressable event with a d tag containing "some reference to the app name and context — or any other arbitrary string." Content and tags can be anything.

NIP-78 is intentionally a blank canvas. No NIP defines:

  • A standard schema for user preferences
  • A convention for d-tag naming
  • A cross-app settings format

This means our d:user-settings convention is ours to define. If it proves useful across the Nostr ecosystem, it could eventually become a NIP proposal for standardized user preferences.

Related NIPs:

  • NIP-44 — The required encryption standard for settings content (NIP-04 is deprecated)
  • NIP-51 — Defines structured lists (kinds 10000-30007) but not app settings
  • NIP-37 — Defines draft wraps (kind 31234) with separate relay lists for private content

3. User-Centric Layout

From the user's npub perspective, their kind 30078 events should be organized into three categories:

Category 1: Cross-App User Preferences (d:user-settings)

Settings that any compatible app should respect. These follow the user across devices and apps:

Namespace Purpose Example consumers
global_llm LLM provider, model, API key, multi-provider config AI pages, skills-edit, Didactyl agent
global_zaps Default zap amount, comment, preferred method, mint allowlist Cashu page, post interactions, any zap button
global_ui Theme, language, accessibility preferences All pages
global_relays Relay preferences All pages
global_experimental Feature flags All pages

The global_ prefix distinguishes cross-app settings from page-specific ones at a glance.

Category 2: Page-Specific Settings (also in d:user-settings)

Layout and UI state that only matters to specific pages within client:

Namespace Purpose Owning page
feed Video autoplay feed.html
post Viewed scroll behavior post.html
notifications Filters, readAt timestamp notifications.html
blobs Grid columns, rows per page blobs.html
vjPage Streaming sites, columns, draft vj.html
strudel Strudel music coding settings strudel pages

No prefix — these are clearly page-local by their names.

Category 3: Standalone Data Events (separate d-tags)

Large data stores that would bloat the centralized settings event:

d-tag Purpose Why standalone
todo Todo list items Can grow large
calorie_foods / calorie_diary Calorie tracking data Separate data domains
links Bookmarks (Netscape HTML) Large, compressed
viewed Read/unread tracking per follow Updates frequently
{conversation-id} AI chat conversations Many events, large
relay-list-{N} Relay registry chunks Intentionally public
show-playlist:{show}:{ts} VJ episode playlists Intentionally public

4. Centralized User Settings Event

Nostr Event Shape

{
  "kind": 30078,
  "tags": [["d", "user-settings"]],
  "content": "<NIP-44 encrypted JSON>",
  "created_at": 1708646400
}
  • d tag: Always "user-settings"
  • Encryption: NIP-44 self-encrypt (sender = recipient = user pubkey)
  • Replaceability: Addressable — publishing a new one replaces the previous on relays

Target Schema (v2)

{
  "v": 2,
  "updatedAt": 1708646400,

  "global_llm": {
    "provider": "ppq",
    "api_key": "sk-...",
    "model": "claude-opus-4.6",
    "base_url": "https://api.ppq.ai",
    "max_tokens": 200000,
    "temperature": 0.7,
    "providers": [
      {
        "name": "ppq",
        "base_url": "https://api.ppq.ai",
        "api_key": "sk-...",
        "models": ["claude-opus-4.6", "claude-haiku-4.5"]
      }
    ],
    "favorites": ["claude-opus-4.6"]
  },

  "global_zaps": {
    "defaultAmountSats": 21,
    "defaultComment": "",
    "preferredMethod": "auto",
    "receiveMintAllowlist": []
  },

  "global_ui": {},
  "global_relays": {},
  "global_experimental": {},

  "feed": { "videoAutoplay": false },
  "post": { "viewed": { "scrollToMark": false } },
  "notifications": { "filters": {}, "readAt": 0 },
  "blobs": { "gridColumns": 4, "rowsPerPage": 10 },
  "vjPage": {
    "streamingSites": [],
    "selectedSiteName": "",
    "streamDraft": {},
    "autoAnnounce": {},
    "columns": {}
  },
  "strudel": {}
}

Current Schema (v1) — Supported

The current implementation uses these namespace names without the global_ prefix:

Current key Target key Status
ui global_ui Rename pending
relays global_relays Rename pending
experimental global_experimental Rename pending
zaps global_zaps Rename pending
ai global_llm Rename + schema alignment pending
post post No change
feed feed No change
notifications notifications No change
blobs blobs No change
vjPage vjPage No change
strudel strudel No change

Default Settings

Defined in getDefaultUserSettings():

{
    v: 1,           // SETTINGS_SCHEMA_VERSION
    updatedAt: 0,
    ui: {},
    post: { viewed: { scrollToMark: false } },
    strudel: {},
    relays: {},
    experimental: {}
}

Feature namespaces not in the defaults (e.g. feed, notifications, zaps, blobs, ai, vjPage) are created on first patch. The deepMerge() function ensures new keys are added without destroying existing ones.

Normalization

normalizeUserSettings() deep-merges any input with the defaults, then forces:

  • v to the current SETTINGS_SCHEMA_VERSION
  • updatedAt to a numeric value

5. Worker Lifecycle

Hydration (startup)

Called by hydrateUserSettingsForPubkey() during handleInit():

1. Read from IndexedDB cache (ndk-shared-settings DB, kv store)
2. Normalize and broadcast to all connected tabs
3. Fetch kind 30078 d:user-settings from relays
4. NIP-44 decrypt the content
5. Compare updatedAt timestamps — relay wins if >= local
6. Write winner to IndexedDB cache
7. Broadcast final settings to all tabs

Publishing

Called by publishUserSettingsNow():

1. Set updatedAt to current unix timestamp
2. Normalize the settings object
3. JSON.stringify the normalized object
4. NIP-44 encrypt (self-encrypt: sender = recipient = user pubkey)
5. Create NDKEvent with kind:30078, tags:[['d','user-settings']]
6. Sign and publish to connected relays

Debounced Publish

scheduleUserSettingsPublish() debounces rapid patches with a 250-350ms delay so multiple quick UI changes result in a single relay publish.


6. Page-Level API

All functions are exported from www/js/init-ndk.mjs and communicate with the SharedWorker via postMessage.

getUserSettings() -> Promise<Object>

Source. Returns the current merged settings object. Times out after 7 seconds.

patchUserSettings(patch, options?) -> Promise<Object>

Source. Deep-merges patch into current settings, writes to cache, broadcasts to all tabs, and (unless options.publish === false) schedules a debounced relay publish.

Convention: Each page patches only its own namespace:

await patchUserSettings({
  myFeature: { someSetting: true }
});

onUserSettings(callback) -> unsubscribe function

Source. Subscribes to live settings updates via the ndkUserSettings custom DOM event. Returns an unsubscribe function.

Standard Page Pattern

// 1. Import
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';

// 2. Initial read
let pageSettings = {};
try {
  pageSettings = await getUserSettings();
} catch (error) {
  pageSettings = {};
}

// 3. Subscribe to live updates
const unsubscribe = onUserSettings((settings) => {
  pageSettings = settings || {};
  applySettings(pageSettings);
});

// 4. Patch your namespace
await patchUserSettings({ myFeature: { key: value } });

7. Standalone Kind 30078 Events

These are not part of the centralized user-settings object. Each page manages its own d-tagged event independently.

d-tag Page Encryption Content Format Description
todo todo.html NIP-04 (legacy) { rows: [todo items] } User todo list
calorie_foods cal.html NIP-04 (legacy) { rows: [food items] } Calorie food database
calorie_diary cal.html NIP-04 (legacy) { rows: [diary entries] } Daily food diary
links links.html NIP-04 + LZW (legacy) Netscape bookmark HTML Saved bookmarks
viewed post.html NIP-44 { v:1, lastGlobalView, follows: {} } Read/unread tracking
relay-list-{N} keep-alive.html Plaintext JSON relay metadata chunks Relay registry
{conversation-id} ai.html NIP-44 { id, title, messages, ... } AI chat conversations
show-playlist:{show}:{ts} vj-stream.mjs Plaintext Episode playlist with track tags VJ episode playlists

Why Standalone?

  • Size: Todo lists, bookmarks, and conversations can be large
  • Update frequency: Conversations and playlists update frequently and independently
  • Different encryption: Some use NIP-04 (legacy), some NIP-44, some are plaintext
  • Different audiences: Relay registry and playlists are intentionally public

8. Cross-Project Alignment: Didactyl

Didactyl's Kind 30078 Usage

The Didactyl agent (a C binary with its own Nostr identity) uses kind 30078 with NIP-44 self-encrypted payloads:

d-tag Source Content
user-settings main.c { v, updatedAt, global_llm, didactyl }
tasks tool_task.c:14 Agent task memory
memory tool_memory.c:14 Agent long-term memory
Any d-tag tool_config.c:128 Generic config_store/config_recall

Didactyl user-settings Shape

Didactyl now stores runtime LLM + agent metadata in a single d:user-settings event under the agent's own pubkey:

{
  "v": 2,
  "updatedAt": 1712345678,
  "global_llm": {
    "provider": "ppq",
    "api_key": "sk-...",
    "model": "claude-opus-4.6",
    "base_url": "https://api.ppq.ai",
    "max_tokens": 200000,
    "temperature": 0.7
  },
  "didactyl": {
    "admin_pubkey": "npub1...",
    "dm_protocol": "nip04"
  }
}

model_set performs read-modify-write of this event by patching global_llm while preserving other namespaces.

Cross-Project Reading

Didactyl does not read admin/user web user-settings for runtime startup. It only reads/writes:

kind:30078, authors:[agent_pubkey], #d:[user-settings]

If a web page wants to inspect agent runtime settings, it should query the agent pubkey's d:user-settings event and read:

  • global_llm for model/provider/api settings
  • didactyl for agent-specific runtime metadata

9. Encryption Standard

Context Method Rationale
Centralized user-settings NIP-44 self-encrypt Modern standard; worker handles via messageSigner
viewed (post read state) NIP-44 self-encrypt Privacy — relays cannot see read state
AI conversations NIP-44 self-encrypt Contains private chat history
Didactyl configs NIP-44 self-encrypt Contains API keys
todo NIP-04 (legacy — migrate) Predates NIP-44
calorie_foods / calorie_diary NIP-04 (legacy — migrate) Predates NIP-44
links NIP-04 + LZW (legacy — migrate) Predates NIP-44
relay-list-{N} Plaintext Intentionally public
VJ episode playlists Plaintext Intentionally public

NIP-04 is deprecated. All legacy pages should migrate to NIP-44.


10. Adding a New Settings Namespace

Step 1: Choose centralized vs standalone

  • Small config that benefits from cross-tab sync -> centralized namespace
  • Large data, high-frequency updates, or different encryption needs -> standalone d-tag
  • Cross-app portable preference -> centralized with global_ prefix
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';

// Read on init
const settings = await getUserSettings();
const myConfig = settings?.myNewFeature || {};

// Subscribe to live updates
onUserSettings((s) => {
  const updated = s?.myNewFeature || {};
  applyMyConfig(updated);
});

// Save changes
await patchUserSettings({
  myNewFeature: { option1: true, option2: 'value' }
});

Step 3: For standalone

import { subscribe, publishEvent } from './js/init-ndk.mjs';

// Read
subscribe(
  { kinds: [30078], authors: [pubkey], '#d': ['my-feature-data'] },
  { closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);

// Write (use NIP-44 encryption via worker messageSigner)
await publishEvent({
  kind: 30078,
  tags: [['d', 'my-feature-data']],
  content: encryptedContent,
  created_at: Math.floor(Date.now() / 1000)
});

Step 4: Document

Add the new namespace to the appropriate table in this file.


11. Audit Findings and Migration Plan

Issue 1: Duplicate AI Config Storage — RESOLVED

Previous problem: AI provider config was stored in two places:

  • skills-edit.html used standalone user-pubkey d:llm_config
  • Other AI pages used centralized user settings (settings.ai / global_llm) via patchUserSettings

Resolution implemented:

  • skills-edit.html now reads from centralized user settings global_llm (with ai fallback for v1 compatibility)
  • skills-edit.html now writes LLM updates via patchUserSettings({ global_llm: ... })
  • Standalone user-pubkey d:llm_config is deprecated
  • Agent-pubkey standalone d:llm_config remains the Didactyl runtime config format

Issue 2: NIP-04 Legacy Encryption — HIGH PRIORITY

Problem: Three pages use deprecated NIP-04 encryption:

  • todo.htmlwindow.nostr.nip04.encrypt()
  • cal.htmlwindow.nostr.nip04.encrypt()
  • links.htmlwindow.nostr.nip04.encrypt() + LZW

Migration: Switch to NIP-44. Add one-time migration: read NIP-04, re-encrypt with NIP-44, republish.

Issue 3: Direct window.nostr Calls — MEDIUM PRIORITY

Problem: Standalone pages call window.nostr.nip04.encrypt/decrypt directly, bypassing the worker's messageSigner infrastructure. This breaks with remote signers/bunkers.

Migration: Route encryption through the worker's messageSigner for consistency.

Issue 4: Inconsistent Filter Syntax — LOW PRIORITY

Problem: cal.html passes filter as an array [{ kinds: [30078], ... }] instead of a plain object.

Migration: Normalize to object form.

Issue 5: Missing cacheUsage: 'CACHE_FIRST' — LOW PRIORITY

Problem: todo.html, cal.html, links.html subscribe without cacheUsage: 'CACHE_FIRST'.

Migration: Add cacheUsage: 'CACHE_FIRST' for faster load times.

Issue 6: Merge calorie_foods + calorie_diary — LOW PRIORITY

Problem: cal.html publishes two separate events that are always loaded and saved together.

Migration: Combine into single d:calorie event: { foods: {...}, diary: {...} }.

Issue 7: Rename to global_ Prefix — DEFERRED

Problem: Current cross-app namespaces (ai, zaps, ui, relays, experimental) lack the global_ prefix.

Migration: Bump schema to v2. In normalizeUserSettings(), detect v1 and migrate: copy ai -> global_llm, zaps -> global_zaps, etc. Support reading both during transition.

Migration Priority Order

  1. Merge skills-edit.html d:llm_config into centralized settings (Issue 1)
  2. Migrate todo.html, cal.html, links.html from NIP-04 to NIP-44 (Issue 2)
  3. Route standalone encryption through worker messageSigner (Issue 3)
  4. Fix cal.html filter syntax + add CACHE_FIRST everywhere (Issues 4, 5)
  5. Merge calorie events (Issue 6)
  6. Rename to global_ prefix with v2 schema migration (Issue 7)

12. Key Source Files

File Role
www/ndk-worker.js Settings state, hydration, publish, cache, normalize
www/js/init-ndk.mjs Page-facing API: getUserSettings, patchUserSettings, onUserSettings
reference_repos/nips/78.md NIP-78 specification for kind 30078
reference_repos/nips/44.md NIP-44 encryption specification
/home/user/lt/didactyl/src/main.c Didactyl agent llm_config and agent_config publish/recall
/home/user/lt/didactyl/src/tools/tool_config.c Didactyl generic config_store/config_recall tool
/home/user/lt/didactyl/src/tools/tool_model.c Didactyl model_set tool (persists to d:llm_config)