# Conversation Bookkeeping System in `.letta/claude/`: How Session Mapping Works in Claude Subconscious

> Learn how the conversation bookkeeping system in .letta/claude/ maps session IDs to conversation and agent IDs for persistent conversation resumption. Understand durable JSON map functionality.

- Repository: [Letta/claude-subconscious](https://github.com/letta-ai/claude-subconscious)
- Tags: internals
- Published: 2026-03-26

---

**The conversation bookkeeping system uses a durable JSON map in [`.letta/claude/conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/.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`](https://github.com/letta-ai/claude-subconscious/blob/main/conversations.json))

The [`conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/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`](https://github.com/letta-ai/claude-subconscious/blob/main/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:

```json
{
  "session-abc123": {
    "conversationId": "conv-xyz456",
    "agentId": "agent-001"
  },
  "session-def789": "conv-oldformat"
}

```

The path resolution logic in [`conversation_utils.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/conversation_utils.ts) uses `getConversationsFile()`:

```ts
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`](https://github.com/letta-ai/claude-subconscious/blob/main/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`](https://github.com/letta-ai/claude-subconscious/blob/main/conversation_utils.ts)

All durable state operations are encapsulated in [`scripts/conversation_utils.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/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:

```ts
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:

```ts
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:

```ts
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`](https://github.com/letta-ai/claude-subconscious/blob/main/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:

1. **Load the global map** using `loadConversationsMap()`
2. **Check for existing entries** using the `session_id` from the hook input
3. **Validate agent consistency** – if the stored `agentId` differs from the current Agent, the system invalidates the stale mapping
4. **Handle format migration** – automatically upgrades legacy string entries to the modern object format
5. **Create or reuse** – generates a new Letta conversation only when necessary
6. **Persist state** – atomically updates [`conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/conversations.json) and writes the per-session state file

The core mapping logic from [`session_start.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/session_start.ts) handles all edge cases:

```ts
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 `conversationId` and `agentId`.
- **Agent Validation**: If the `agentId` stored 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:

```ts
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:

```ts
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:

```ts
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 under `LETTA_HOME`.
- [`conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/conversations.json) maintains the **canonical mapping** between Claude Code `session_id` values and Letta `conversation_id` values, including the `agentId` for validation.
- `session-<sessionId>.json` files track **per-session sync state** including transcript processing indices.
- [`scripts/conversation_utils.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/scripts/conversation_utils.ts) provides atomic file operations and format-compatible lookup functions.
- [`scripts/session_start.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/scripts/session_start.ts) orchestrates 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`](https://github.com/letta-ai/claude-subconscious/blob/main/session_start.ts) hook detects that the stored `agentId` in [`conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/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`](https://github.com/letta-ai/claude-subconscious/blob/main/.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`](https://github.com/letta-ai/claude-subconscious/blob/main/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`](https://github.com/letta-ai/claude-subconscious/blob/main/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.