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
- Overview
- NIP-78 Context
- User-Centric Layout
- Centralized User Settings Event
- Worker Lifecycle
- Page-Level API
- Standalone Kind 30078 Events
- Cross-Project Alignment: Didactyl
- Encryption Standard
- Adding a New Settings Namespace
- Audit Findings and Migration Plan
- Key Source Files
1. Overview
The app uses NIP-78 Application-specific Data (kind:30078) for two distinct purposes:
- 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. - Standalone Page Data — Individual addressable events with page-specific
dtags 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
}
dtag: 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:
vto the currentSETTINGS_SCHEMA_VERSIONupdatedAtto 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_llmfor model/provider/api settingsdidactylfor 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)
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.htmlused standalone user-pubkeyd:llm_config- Other AI pages used centralized user settings (
settings.ai/global_llm) viapatchUserSettings
Resolution implemented:
skills-edit.htmlnow reads from centralized user settingsglobal_llm(withaifallback for v1 compatibility)skills-edit.htmlnow writes LLM updates viapatchUserSettings({ global_llm: ... })- Standalone user-pubkey
d:llm_configis deprecated - Agent-pubkey standalone
d:llm_configremains the Didactyl runtime config format
Issue 2: NIP-04 Legacy Encryption — HIGH PRIORITY
Problem: Three pages use deprecated NIP-04 encryption:
todo.html—window.nostr.nip04.encrypt()cal.html—window.nostr.nip04.encrypt()links.html—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 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
- Merge
skills-edit.htmld:llm_configinto centralized settings (Issue 1) - Migrate
todo.html,cal.html,links.htmlfrom NIP-04 to NIP-44 (Issue 2) - Route standalone encryption through worker messageSigner (Issue 3)
- Fix
cal.htmlfilter syntax + add CACHE_FIRST everywhere (Issues 4, 5) - Merge calorie events (Issue 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) |