This document is for developers working on conversation persistence.
Note: Proton's API uses
spacefor conversation containers. The WebClient UI usesproject. We follow this: config and logs useproject(user-facing), internal code keepsspaceto match the API.
conversations:
maxInMemory: 100 # Max conversations in memory (LRU eviction)
deriveIdFromUser: false # For stateless clients (Home Assistant)
sync:
enabled: true
projectName: lumo-tamer # Project name (created if doesn't exist)
# projectId: "uuid" # Or use specific project UUID
includeSystemMessages: false # Only sync user/assistant messages
autoSync: false # Or use /save commandConversation titles are auto-generated on the first message, following Proton's WebClient pattern.
- When a new conversation is created (title =
'New Conversation'),requestTitle: trueis passed to the LLM - The API streams title chunks alongside the message (targets:
['title', 'message']) - Title is post-processed: quotes removed, trimmed, max 100 chars
- Title is saved to
ConversationStoreand synced with the conversation
Send /save as a message to sync all dirty conversations:
curl -X POST http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-d '{"model": "lumo", "messages": [{"role": "user", "content": "/save"}]}'When sync.autoSync: true:
ConversationStore.markDirty()notifiesAutoSyncService- Debounce: Waits for activity to settle
- Throttle: Respects minimum interval
- Max delay: Forces sync after timeout
Proton's backend enforces a per-project conversation limit. Deleted conversations count towards this limit. When reached, sync fails with HTTP 422 "You've reached maximum number of conversations". Conversations remain usable locally but are not persisted server-side. Use a new projectName to work around this. See #16.
Cause: Your API client isn't providing a conversation identifier, so lumo-tamer treats requests as stateless (no persistence, no sync).
lumo-tamer needs a way to know which messages belong to the same conversation. Without this, each request is treated as independent and nothing gets synced.
Solution: Configure your client to send a conversation identifier.
For /v1/responses (OpenAI Responses API):
- Include
"conversation": "your-conversation-id"in your request, OR - Use
previous_response_idto chain responses together, OR - Include
"user": "unique-session-id"withderiveIdFromUser: true
For /v1/chat/completions (OpenAI Chat Completions API):
Include "user": "unique-session-id" in your request and enable conversations.deriveIdFromUser: true in config.yaml
Example with user field (works for both endpoints):
# config.yaml
conversations:
deriveIdFromUser: true
sync:
enabled: true// Your API request
{
"model": "lumo",
"user": "session-abc123",
"messages": [{"role": "user", "content": "Hello"}]
}How to verify it's working:
- Check logs for
Persisted conversation messages(stateful) vs no persistence log (stateless) - Check logs for
Generated titleon first message of a new conversation - After sync, check Proton Lumo WebClient for the conversation
Note for Home Assistant users: Home Assistant automatically sets the user field to its internal conversation ID, so enabling deriveIdFromUser: true is usually all you need.
Two-tier persistence:
API Clients (OpenAI format)
-> ConversationStore (in-memory, LRU eviction)
-> SyncService -> LumoApi (upstream) -> Fetch Adapter -> ProtonApi -> /api/lumo/v1/
Goal: Share conversations between lumo-tamer and Proton WebClient.
Proton's persistence is tightly coupled to their stack:
- IndexedDB layer (
DbApi) - Clean, but requiresfake-indexeddbpolyfill for Node.js - Sync orchestration - Lives in Redux sagas, deeply coupled to generators and Redux state
We reuse:
- LumoApi - Pulled upstream unchanged; integrated via a fetch adapter that routes API calls through our authenticated ProtonApi
- Encryption scheme - Same key hierarchy and AEAD format (compatible with WebClient)
We implement our own:
- ConversationStore - Simple in-memory store with LRU eviction
- SyncService - Direct sync without saga complexity, delegates to LumoApi
- AutoSyncService - Timer-based debounce/throttle
src/conversations/
├── store.ts # In-memory store with LRU
├── deduplication.ts # Message hash deduplication
├── types.ts # Core types
├── encryption/
│ └── key-manager.ts # Master/space key management
└── sync/
├── sync-service.ts # Manual sync to server
├── auto-sync.ts # Automatic sync scheduling
└── lumo-api.ts # Upstream LumoApi wrapper
| File | Purpose |
|---|---|
| src/conversations/conversation-store.ts | In-memory store |
| src/conversations/sync/sync-service.ts | Server sync |
| src/conversations/sync/auto-sync.ts | Auto-sync scheduling |
| src/conversations/encryption/key-manager.ts | Key management |
| src/conversations/sync/lumo-api.ts | Upstream LumoApi wrapper |
| src/proton-upstream/remote/api.ts | LumoApi (upstream, unchanged) |
| src/proton-shims/fetch-adapter.ts | Routes LumoApi fetch calls to ProtonApi |
| src/app/commands.ts | /save, /title commands |
| src/proton-shims/lumo-api-client-utils.ts | postProcessTitle() |
Reference material based on analysis of ~/WebClients/applications/lumo/src/app/.
Three-tier persistence:
UI (React) -> Redux -> Saga Middleware -> IndexedDB (local) + Remote API (server)
- Redux - In-memory state for fast UI
- IndexedDB - Local encrypted storage, offline-first
- Remote API - Server-side persistence (
/api/lumo/v1/)
Container for conversations with its own encryption key.
type Space = {
id: SpaceId; // UUID
createdAt: string;
spaceKey: Base64; // HKDF-derived, wrapped with master key
};type Conversation = {
id: ConversationId;
spaceId: SpaceId;
title: string; // Encrypted
starred?: boolean;
status?: 'generating' | 'completed';
ghost?: boolean; // Transient, not persisted
};type Message = {
id: MessageId;
conversationId: ConversationId;
role: 'user' | 'assistant' | 'system' | 'tool_call' | 'tool_result';
parentId?: MessageId; // For branching
content?: string; // Encrypted
status?: 'succeeded' | 'failed';
};type LocalFlags = {
dirty?: boolean; // Needs sync to server
deleted?: boolean; // Soft delete
};Base URL: /api/lumo/v1/
| Resource | Endpoints |
|---|---|
| Spaces | GET/POST /spaces, GET/PUT/DELETE /spaces/{id} |
| Conversations | POST /spaces/{spaceId}/conversations, GET/PUT/DELETE /conversations/{id} |
| Messages | POST /conversations/{id}/messages, GET /messages/{id} |
| Master Keys | GET/POST /masterkeys |
User PGP Key (decrypted with mailbox password)
-> Master Key (PGP-encrypted on server)
-> Space Key (AES-KW wrapped with master key)
-> Data Encryption Key (HKDF-derived from space key)
-> Content (AES-GCM with AEAD)
- Master Key: Fetched from
/lumo/v1/masterkeys, decrypted with user's PGP private key - Space Key: Generated per-space, wrapped with master key using AES-KW
- Data Encryption Key (DEK): Derived from space key using HKDF with fixed salt
- Content: Encrypted with AES-GCM using DEK
All encrypted content uses associated data to bind ciphertext to its context, preventing substitution attacks. The AD is a JSON object with alphabetically sorted keys (via json-stable-stringify):
// Space AD
{"app":"lumo","id":"<spaceId>","type":"space"}
// Conversation AD
{"app":"lumo","id":"<conversationId>","spaceId":"<spaceId>","type":"conversation"}
// Message AD
{"app":"lumo","conversationId":"<convId>","id":"<messageId>","parentId":"<parentId>","role":"user|assistant","type":"message"}- Dirty flags: Items marked
dirty: trueneed sync - Sagas: Redux-saga orchestrates sync with debouncing (
noRaceSameId) - Retry: 30s intervals on failure
- Ghost mode:
ghost: trueconversations skip persistence
| Path | Purpose |
|---|---|
src/app/types.ts |
Data structures |
src/app/remote/api.ts |
HTTP client |
src/app/indexedDb/db.ts |
IndexedDB operations |
src/app/redux/sagas/conversations.ts |
Sync orchestration |
src/app/serialization.ts |
Encryption helpers |