# 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](#1-overview) 2. [NIP-78 Context](#2-nip-78-context) 3. [User-Centric Layout](#3-user-centric-layout) 4. [Centralized User Settings Event](#4-centralized-user-settings-event) 5. [Worker Lifecycle](#5-worker-lifecycle) 6. [Page-Level API](#6-page-level-api) 7. [Standalone Kind 30078 Events](#7-standalone-kind-30078-events) 8. [Cross-Project Alignment: Didactyl](#8-cross-project-alignment-didactyl) 9. [Encryption Standard](#9-encryption-standard) 10. [Adding a New Settings Namespace](#10-adding-a-new-settings-namespace) 11. [Audit Findings and Migration Plan](#11-audit-findings-and-migration-plan) 12. [Key Source Files](#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. ```mermaid 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](../reference_repos/nips/78.md) 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 ```json { "kind": 30078, "tags": [["d", "user-settings"]], "content": "", "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) ```json { "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()`](../www/ndk-worker.js:324): ```js { 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()`](../www/ndk-worker.js:344) function ensures new keys are added without destroying existing ones. ### Normalization [`normalizeUserSettings()`](../www/ndk-worker.js:359) 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()`](../www/ndk-worker.js:3992) during [`handleInit()`](../www/ndk-worker.js:4271): ``` 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()`](../www/ndk-worker.js:4018): ``` 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()`](../www/ndk-worker.js:4055) 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`](../www/js/init-ndk.mjs) and communicate with the SharedWorker via `postMessage`. ### `getUserSettings()` -> `Promise` [Source](../www/js/init-ndk.mjs:956). Returns the current merged settings object. Times out after 7 seconds. ### `patchUserSettings(patch, options?)` -> `Promise` [Source](../www/js/init-ndk.mjs:985). 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: ```js await patchUserSettings({ myFeature: { someSetting: true } }); ``` ### `onUserSettings(callback)` -> `unsubscribe function` [Source](../www/js/init-ndk.mjs:1015). Subscribes to live settings updates via the `ndkUserSettings` custom DOM event. Returns an unsubscribe function. ### Standard Page Pattern ```js // 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`](../www/todo.html:831) | NIP-04 (legacy) | `{ rows: [todo items] }` | User todo list | | `calorie_foods` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [food items] }` | Calorie food database | | `calorie_diary` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [diary entries] }` | Daily food diary | | `links` | [`links.html`](../www/links.html:497) | NIP-04 + LZW (legacy) | Netscape bookmark HTML | Saved bookmarks | | `viewed` | [`post.html`](../www/post.html) | NIP-44 | `{ v:1, lastGlobalView, follows: {} }` | Read/unread tracking | | `relay-list-{N}` | [`keep-alive.html`](../www/keep-alive.html:1162) | Plaintext | JSON relay metadata chunks | Relay registry | | `{conversation-id}` | [`ai.html`](../www/ai.html:788) | NIP-44 | `{ id, title, messages, ... }` | AI chat conversations | | `show-playlist:{show}:{ts}` | [`vj-stream.mjs`](../www/js/vj-stream.mjs:5) | 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`](/home/user/lt/didactyl/src/main.c) | `{ v, updatedAt, global_llm, didactyl }` | | `tasks` | [`tool_task.c:14`](/home/user/lt/didactyl/src/tools/tool_task.c:14) | Agent task memory | | `memory` | [`tool_memory.c:14`](/home/user/lt/didactyl/src/tools/tool_memory.c:14) | Agent long-term memory | | Any d-tag | [`tool_config.c:128`](/home/user/lt/didactyl/src/tools/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**: ```json { "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 ### Step 2: For centralized (recommended for most page settings) ```js 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 ```js 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.html`](../www/todo.html:822) — `window.nostr.nip04.encrypt()` - [`cal.html`](../www/cal.html:972) — `window.nostr.nip04.encrypt()` - [`links.html`](../www/links.html:501) — `window.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`](../www/cal.html:997) 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`](../www/todo.html:863), [`cal.html`](../www/cal.html:998), [`links.html`](../www/links.html:434) 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`](../www/cal.html:1103) 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`](../www/ndk-worker.js:311) | Settings state, hydration, publish, cache, normalize | | [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs:956) | Page-facing API: getUserSettings, patchUserSettings, onUserSettings | | [`reference_repos/nips/78.md`](../reference_repos/nips/78.md) | NIP-78 specification for kind 30078 | | [`reference_repos/nips/44.md`](../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) |