Conversation Bookkeeping System in `.letta/claude/`: How Session Mapping Works in Claude Subconscious
The conversation bookkeeping system uses a durable JSON map in .letta/claude/conversations.json to permanently link each Claude Code session_id to its Letta conversation_id and agent_id, enabling persistent conversation resumption across restarts.
The .letta/claude/ directory serves as the persistent state layer for the letta-ai/claude-subconscious plugin. This conversation bookkeeping system ensures that every Claude Code session maintains a stable, retrievable connection to its corresponding Letta conversation, even across terminal restarts or working directory changes.
Understanding the .letta/claude/ Directory Structure
The bookkeeping system relies on two distinct file types stored within the .letta/claude/ directory. By default, this directory resides in the project working directory, but it relocates to ~/.letta/claude/ when the LETTA_HOME environment variable is set.
Global Session Map (conversations.json)
The conversations.json file acts as the central registry that maps Claude Code session identifiers to Letta conversation metadata. According to the source code in scripts/conversation_utils.ts, this file stores a JSON object where each key is a Claude Code session_id.
The structure supports both modern and legacy formats:
{
"session-abc123": {
"conversationId": "conv-xyz456",
"agentId": "agent-001"
},
"session-def789": "conv-oldformat"
}
The path resolution logic in conversation_utils.ts uses getConversationsFile():
export function getConversationsFile(cwd: string): string {
return path.join(getDurableStateDir(cwd), 'conversations.json');
}
Per-Session Sync State (session-<id>.json)
Each active session receives its own sync state file (e.g., session-abc123.json) that tracks the transcript processing position and caches the conversationId for quick access. This separation allows the system to maintain individual cursor positions while the global map handles the session-to-conversation relationships.
Core Bookkeeping Functions in conversation_utils.ts
All durable state operations are encapsulated in scripts/conversation_utils.ts, which provides deterministic file path resolution and atomic read/write operations.
Directory Initialization
The system determines the storage location through getDurableStateDir(), respecting the LETTA_HOME environment variable:
export function getDurableStateDir(cwd: string): string {
const base = process.env.LETTA_HOME || cwd;
return path.join(base, '.letta', 'claude');
}
The ensureDurableStateDir() function creates the directory on-demand before any write operations.
Loading and Persisting the Session Map
The conversation bookkeeping system uses two primary functions for map management:
export function loadConversationsMap(cwd: string, log: LogFn = noopLog): ConversationsMap {
const filePath = getConversationsFile(cwd);
if (fs.existsSync(filePath)) {
try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); }
catch (e) { log(`Failed to load conversations map: ${e}`); }
}
return {};
}
export function saveConversationsMap(cwd: string, map: ConversationsMap): void {
ensureDurableStateDir(cwd);
fs.writeFileSync(getConversationsFile(cwd), JSON.stringify(map, null, 2), 'utf-8');
}
Session Lookup with Format Compatibility
The lookupConversation() function retrieves conversation IDs while handling the legacy string format:
export function lookupConversation(cwd: string, sessionId: string): string | null {
const conversationsFile = getConversationsFile(cwd);
if (!fs.existsSync(conversationsFile)) return null;
try {
const map: ConversationsMap = JSON.parse(fs.readFileSync(conversationsFile, 'utf-8'));
const cached = map[sessionId];
return typeof cached === 'string' ? cached : cached?.conversationId ?? null;
} catch { return null; }
}
How Sessions Are Mapped at Startup
The session mapping logic executes within scripts/session_start.ts every time Claude Code initializes. This hook ensures idempotent session resumption by checking the global map before creating new conversations.
The workflow follows this sequence:
- Load the global map using
loadConversationsMap() - Check for existing entries using the
session_idfrom the hook input - Validate agent consistency – if the stored
agentIddiffers from the current Agent, the system invalidates the stale mapping - Handle format migration – automatically upgrades legacy string entries to the modern object format
- Create or reuse – generates a new Letta conversation only when necessary
- Persist state – atomically updates
conversations.jsonand writes the per-session state file
The core mapping logic from session_start.ts handles all edge cases:
const cached = conversationsMap[hookInput.session_id];
if (cached) {
const entry = typeof cached === 'string'
? { conversationId: cached, agentId: null as string | null }
: cached;
if (entry.agentId && entry.agentId !== agentId) {
// Agent changed → recreate conversation
delete conversationsMap[hookInput.session_id];
conversationId = await createConversation(apiKey, agentId, log);
conversationsMap[hookInput.session_id] = { conversationId, agentId };
} else if (!entry.agentId) {
// Upgrade old format
delete conversationsMap[hookInput.session_id];
conversationId = await createConversation(apiKey, agentId, log);
conversationsMap[hookInput.session_id] = { conversationId, agentId };
} else {
// Reuse existing conversation
conversationId = entry.conversationId;
}
} else {
// First time for this session
conversationId = await createConversation(apiKey, agentId, log);
conversationsMap[hookInput.session_id] = { conversationId, agentId };
}
saveConversationsMap(hookInput.cwd, conversationsMap);
Handling Legacy Formats and Agent Changes
The conversation bookkeeping system maintains backward compatibility while ensuring data integrity through two specific safeguards:
- Format Migration: When encountering the legacy string format (where the map value is just a conversation ID string), the system deletes the old entry, creates a new conversation with the current Agent, and stores the enriched object format containing both
conversationIdandagentId. - Agent Validation: If the
agentIdstored in the map does not match the currently configured Agent (from~/.letta/claude-subconscious/config.json), the system treats this as a configuration change and generates a fresh conversation to prevent context leakage between different Agents.
Practical Code Examples
Inspecting the Current Conversation Map
You can programmatically inspect the session mappings using the utility functions:
import { getDurableStateDir, loadConversationsMap } from './scripts/conversation_utils.js';
import path from 'path';
const cwd = process.cwd();
const dir = getDurableStateDir(cwd);
const map = loadConversationsMap(cwd);
console.log('Conversation map stored in:', path.join(dir, 'conversations.json'));
console.log(JSON.stringify(map, null, 2));
Resolving a Conversation ID for a Specific Session
To look up which Letta conversation corresponds to a known Claude Code session:
import { lookupConversation } from './scripts/conversation_utils.js';
const cwd = '/my/project';
const sessionId = 'session-abc123';
const convId = lookupConversation(cwd, sessionId);
if (convId) {
console.log(`Found Letta conversation ${convId} for session ${sessionId}`);
} else {
console.log('No conversation mapped – a new one will be created on next startup');
}
Manually Creating a New Conversation Mapping
For advanced use cases requiring manual conversation initialization:
import { createConversation, loadConversationsMap, saveConversationsMap } from './scripts/conversation_utils.js';
async function registerNewConversation(apiKey, agentId, cwd, sessionId) {
const convId = await createConversation(apiKey, agentId);
const map = loadConversationsMap(cwd);
map[sessionId] = { conversationId: convId, agentId };
saveConversationsMap(cwd, map);
console.log(`Registered conversation ${convId} for session ${sessionId}`);
}
Summary
- The
.letta/claude/directory functions as a durable state store located either in the project directory or underLETTA_HOME. conversations.jsonmaintains the canonical mapping between Claude Codesession_idvalues and Lettaconversation_idvalues, including theagentIdfor validation.session-<sessionId>.jsonfiles track per-session sync state including transcript processing indices.scripts/conversation_utils.tsprovides atomic file operations and format-compatible lookup functions.scripts/session_start.tsorchestrates the mapping logic, handling Agent changes and legacy format migration automatically.- The system guarantees one Letta conversation per Claude Code session with stable resumption across restarts.
Frequently Asked Questions
What happens if I change my Letta Agent configuration?
When the session_start.ts hook detects that the stored agentId in conversations.json differs from the current Agent (retrieved from ~/.letta/claude-subconscious/config.json), it deletes the stale mapping and creates a fresh Letta conversation. This prevents conversation context from leaking between different Agents.
Can I move my conversation history to a different machine?
Yes. The conversation bookkeeping system stores all mappings in .letta/claude/conversations.json and session states in session-*.json files. If you set the LETTA_HOME environment variable to a synchronized directory (e.g., Dropbox or a dotfiles repo), the plugin will locate the same mappings across multiple machines.
Why does my session sometimes create a new conversation unexpectedly?
This occurs when the per-session sync file (session-<id>.json) is deleted or corrupted, but the global map entry is also missing or invalidated. The lookupConversation() function returns null if the map file does not exist, triggering the creation logic in session_start.ts. Ensure your .letta/ directory is not being cleaned by aggressive temporary file cleaners.
How does the system handle interrupted writes?
The implementation in conversation_utils.ts uses atomic writeFileSync operations with synchronous JSON serialization. While there is no explicit write-ahead logging, the synchronous Node.js filesystem operations minimize the window for corruption. If a read operation encounters malformed JSON, the functions catch the exception and return empty defaults or null, allowing the system to recover gracefully by generating new conversation mappings.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →