12 KiB
Global LLM Config Alignment — Plan
Align LLM configuration storage across Didactyl (C agent) and client-ndk (web pages) so both projects read and write the same canonical schema, as specified in SETTINGS.md.
Problem Statement
LLM provider config is currently stored in two incompatible ways:
| Project | Storage | d-tag | Namespace | Schema |
|---|---|---|---|---|
| Didactyl agent | kind 30078 under agent pubkey |
llm_config |
flat root | { provider, api_key, model, base_url, max_tokens, temperature } |
| client-ndk pages | kind 30078 under user pubkey |
user-settings |
settings.ai |
{ provider, api_key, model, base_url, max_tokens, temperature, providers[], favorites[] } |
| skills-edit.html | kind 30078 under user pubkey |
llm_config |
flat root | Same as Didactyl — standalone event |
This creates three issues:
- skills-edit.html writes a standalone
d:llm_configevent under the user pubkey, duplicating the centralizedsettings.aidata - Didactyl cannot read the admin user's LLM preferences from their
d:user-settingsevent - Namespace mismatch — the centralized settings use
ai(v1) /global_llm(v2 target) while Didactyl uses flat fields at root level
Target State
Per SETTINGS.md §3 and §8:
Canonical Schema — global_llm namespace
{
"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"]
}
Storage locations after alignment
| Actor | Event | d-tag | Where LLM lives | Notes |
|---|---|---|---|---|
| User pubkey | kind 30078 |
user-settings |
global_llm namespace |
Canonical source for user LLM prefs |
| Agent pubkey | kind 30078 |
llm_config |
flat root | Agent runtime config — flat fields are a subset of global_llm |
| User pubkey | kind 30078 |
llm_config |
DEPRECATED | skills-edit.html stops writing this |
Cross-project reading
graph LR
subgraph User Pubkey Events
US[d:user-settings<br/>global_llm namespace]
end
subgraph Agent Pubkey Events
AL[d:llm_config<br/>flat fields]
end
subgraph Consumers
SE[skills-edit.html]
AI[ai.html / skills-tv.html]
DA[Didactyl Agent]
end
SE -->|getUserSettings - global_llm| US
AI -->|getUserSettings - global_llm| US
DA -->|recall own d:llm_config| AL
DA -->|optionally read admin global_llm| US
Detailed Changes
Phase 1: genesis.jsonc alignment
File: genesis.jsonc
Current "llm" section already uses compatible flat field names. The only issue is provider contains a URL ("https://api.ppq.ai") instead of a short name ("ppq").
Change: Update provider to be a short name. The base_url field already carries the URL.
"llm": {
"provider": "ppq", // was "https://api.ppq.ai"
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7
}
Also update genesis.jsonc.example to match.
No C struct changes needed — llm_config_t.provider is char[32] which holds short names fine.
Phase 2: Didactyl C-side — persist writes global_llm-compatible fields
File: src/tools/tool_model.c — persist_llm_config_nostr()
Already writes: provider, api_key, model, base_url, max_tokens, temperature — these are the exact flat fields in global_llm. No change needed to the persist path.
File: src/main.c — persist_runtime_config_to_nostr()
Same flat fields. No change needed.
Phase 3: Didactyl C-side — recall from admin d:user-settings
File: src/main.c — recover_missing_runtime_config_from_nostr()
Currently only queries the agent's own pubkey for d:llm_config. Add a fallback path:
- After failing to find own
d:llm_config, query admin pubkey ford:user-settings - Decrypt with NIP-44 using admin pubkey as sender
- Parse JSON, extract
global_llmobject (fall back toaifor v1 compat) - Pass the extracted object to
apply_recalled_llm_config()
File: src/main.c — apply_recalled_llm_config()
Already reads flat fields from a JSON object. If we pass it the global_llm sub-object, it works as-is. It ignores unknown fields like providers and favorites. No change needed.
New function: fetch_admin_user_settings_llm() in main.c:
static int fetch_admin_user_settings_llm(didactyl_config_t* cfg, char** out_plaintext) {
// 1. Query kind:30078, authors:[admin_pubkey], #d:["user-settings"]
// 2. NIP-44 decrypt content (admin encrypted to self — agent cannot decrypt)
// WAIT: Agent cannot decrypt admin's self-encrypted content!
// This path only works if admin explicitly shares config with agent.
// Alternative: Agent reads its OWN d:user-settings if one exists.
// 3. Parse JSON, extract global_llm or ai sub-object
// 4. Serialize sub-object to *out_plaintext
}
Important constraint: The admin's d:user-settings is NIP-44 self-encrypted (admin encrypts to admin). The agent cannot decrypt it because it does not have the admin's private key.
Revised approach: Instead of reading the admin's settings directly, the agent should:
- Primary: Read its own
d:llm_config(current behavior — works) - Secondary: If the admin wants to push LLM config to the agent, they use the
model_settool via DM, which callspersist_llm_config_nostr()to writed:llm_configunder the agent pubkey - New path: The web UI (skills-edit or a future agent-config page) can write
d:llm_configunder the agent's pubkey by publishing a NIP-44 encrypted event addressed to the agent. The agent can then decrypt this on recall.
Actually, re-reading SETTINGS.md §8:
When Didactyl wants to know the user's LLM preferences:
kind:30078, authors:[admin_pubkey], #d:[user-settings]Parse theglobal_llmfield.
This implies the agent CAN read it. But the content is NIP-44 self-encrypted by the admin. The agent would need the admin to encrypt a copy for the agent, OR the admin publishes their settings unencrypted (unlikely for API keys), OR there is a shared-secret mechanism.
Resolution: The practical cross-project path is:
- The web UI reads the user's own
global_llmfromd:user-settings(it can decrypt its own data) - The web UI can optionally push config to the agent's
d:llm_configevent (encrypt to agent pubkey) - The agent reads its own
d:llm_configas today - If the agent needs admin LLM config at startup, the genesis.jsonc provides it, and the setup wizard persists it to
d:llm_config
So Phase 3 simplifies to: no C-side recall changes needed for reading admin settings. The alignment is about ensuring the JSON field names are compatible so the web UI can bridge the two.
Phase 4: skills-edit.html — migrate to centralized global_llm
File: ../client-ndk/www/skills-edit.html
Current behavior:
fetchNostrLlmConfig()subscribes tokind:30078, #d:['llm_config']under user pubkey- Decrypts and parses the standalone event
- Merges into
aiConfig
New behavior:
- Remove
fetchNostrLlmConfig()entirely - In
initializeLlmHelperFromSettings(), read frompageSettings.global_llm(with fallback topageSettings.aifor v1 compat) - The
pageSettingsobject is already populated bygetUserSettings()and kept live byonUserSettings() - When the user changes provider/model via the LLM helper, patch back via
patchUserSettings({ global_llm: { ... } })
Specific changes:
- Remove
fetchNostrLlmConfig()function (~120 lines) - Remove
llmConfigLoadNoncevariable - Update
initializeLlmHelperFromSettings():
async function initializeLlmHelperFromSettings() {
aiConfig = loadAiConfigLocal(aiConfig || getDefaultAiConfig(), AI_STORAGE_KEY);
// Read from centralized settings (global_llm with ai fallback)
const nostrLlm = pageSettings?.global_llm || pageSettings?.ai;
if (nostrLlm && typeof nostrLlm === 'object') {
aiConfig = mergeAiConfigFromSettings(aiConfig, nostrLlm);
}
aiConfig = saveAiConfigLocal(aiConfig, AI_STORAGE_KEY);
renderLlmProviderOptions();
renderLlmModelOptions();
syncLlmHelperFromSkillInput();
fetchLlmModels();
}
- Update
selectLlmProvider()andtoggleCurrentModelFavorite()to also patch centralized settings:
// After saving to local storage, also persist to centralized settings
await patchUserSettings({
global_llm: {
provider: aiConfig.provider,
api_key: aiConfig.api_key,
model: aiConfig.model,
base_url: aiConfig.base_url,
max_tokens: aiConfig.max_tokens,
temperature: aiConfig.temperature,
providers: aiConfig.providers,
favorites: aiConfig.favorites
}
});
- Add
patchUserSettingsto the imports frominit-ndk.mjs
Phase 5: ai-ui.mjs — support global_llm namespace
File: ../client-ndk/www/js/ai-ui.mjs
The mergeAiConfigFromSettings() function already accepts any object with the right flat fields. It works with both settings.ai and settings.global_llm — the caller just passes the right sub-object.
No changes needed to ai-ui.mjs itself. The callers (skills-edit.html and other pages) just need to read from global_llm instead of ai.
Phase 6: Update SETTINGS.md
Mark Issue 1 as resolved. Update the audit findings section to reflect that skills-edit.html now uses centralized global_llm.
Files Modified
| File | Project | Change |
|---|---|---|
genesis.jsonc |
didactyl | Fix provider to short name |
genesis.jsonc.example |
didactyl | Fix provider to short name |
../client-ndk/www/skills-edit.html |
client-ndk | Remove fetchNostrLlmConfig, read from pageSettings.global_llm, add patchUserSettings import and calls |
../client-ndk/docs/SETTINGS.md |
client-ndk | Mark Issue 1 resolved |
What Does NOT Change
| Component | Why |
|---|---|
llm_config_t C struct |
Field names already match global_llm flat fields |
persist_llm_config_nostr() in tool_model.c |
Already writes compatible flat fields |
apply_recalled_llm_config() in main.c |
Already reads compatible flat fields, ignores extras |
persist_runtime_config_to_nostr() in main.c |
Already writes compatible flat fields |
Agent's own d:llm_config event |
Stays as agent runtime config under agent pubkey |
ai-ui.mjs |
mergeAiConfigFromSettings() already handles the right shape |
ndk-worker.js |
Settings infrastructure unchanged — just a new namespace key |
Migration Safety
- v1 → v2 compat: skills-edit.html reads
global_llm || aiso it works with both old and new settings - Local storage fallback:
loadAiConfigLocal()still provides defaults if no Nostr settings exist - No breaking change for agent: Agent continues reading its own
d:llm_config— the flat field names are already compatible - Standalone
d:llm_configunder user pubkey: Becomes orphaned but harmless. Can be cleaned up later.
Encryption Constraint
The agent cannot decrypt the admin's d:user-settings because it is NIP-44 self-encrypted by the admin. Cross-project LLM config sharing works through:
- Genesis file — admin provides initial LLM config
- Setup wizard — persists to agent's own
d:llm_config - model_set tool — admin sends DM to update agent LLM config at runtime
- Future: Web UI could publish a
d:llm_configevent encrypted to the agent's pubkey
This is the correct architecture — the agent should not need to read the admin's private settings directly.