# How Claude Subconscious Achieves Two-Way Communication with Claude Code

> Discover how Claude Subconscious achieves two-way communication with Claude Code using three hook scripts for persistent conversations, XML transcript updates, and agent response injection.

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

---

**Claude Subconscious enables two-way communication with Claude Code through three cooperating hook scripts that create a persistent Letta conversation, stream transcript updates via XML messages, and asynchronously inject agent responses back into the Claude Code context.**

Claude Subconscious is a Letta agent designed to observe Claude Code sessions and maintain persistent memory across interactions. The `letta-ai/claude-subconscious` repository implements this two-way communication with Claude Code using TypeScript hook scripts that run inside Claude Code, allowing the Subconscious to both receive session updates and reply to the user asynchronously.

## The Three-Hook Architecture

The bidirectional channel relies on three distinct hook scripts that coordinate the flow of information between Claude Code and the Letta agent.

### SessionStart Hook: Establishing the Back-Channel

The [`scripts/session_start.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/scripts/session_start.ts) script initiates the connection when a new Claude Code session begins. It creates or reuses a Letta conversation, stores the mapping in a durable [`conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/conversations.json) file, and sends a special `<claude_code_session_start>` XML message to the Letta agent.

According to the source code in [`session_start.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/session_start.ts) (lines 67-78), the script first creates a durable directory for the mapping. Then, as shown in lines 166-176, it constructs and sends the XML payload containing the project name, current working directory, session ID, and SDK tools mode:

```typescript
// scripts/session_start.ts – create or reuse a Letta conversation
const conversationId = await createConversation(apiKey, agentId, log);
// …
const message = `<claude_code_session_start>
<project>${projectName}</project>
<path>${cwd}</path>
<session_id>${sessionId}</session_id>
<timestamp>${timestamp}</timestamp>
<sdk_tools_mode>${sdkToolsMode}</sdk_tools_mode>
...
</claude_code_session_start>`;
await fetch(buildLettaApiUrl(`/conversations/${conversationId}/messages`), …);

```

The conversation ID is persisted using `conversationUtils.saveConversationsMap` to [`conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/conversations.json), enabling subsequent hooks to locate the same Letta conversation.

### Stop Hook: Pushing Updates to Letta

The [`scripts/send_messages_to_letta.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/scripts/send_messages_to_letta.ts) script runs at the end of each Claude Code prompt to transmit the session transcript to the Subconscious. It reads the transcript using `readTranscript`, formats new messages as `<claude_code_session_update>` XML, and delegates the actual sending to a background worker.

Lines 48-78 handle the transcript reading, while lines 84-95 format the new entries. Lines 128-131 launch the detached worker:

```typescript
// scripts/send_messages_to_letta.ts – read transcript, format, and spawn worker
const messages = await readTranscript(hookInput.transcript_path, log);
const newMessages = formatMessagesForLetta(messages, state.lastProcessedIndex, log);
const payload = {
  agentId,
  conversationId,
  sessionId: hookInput.session_id,
  message: userMessage,   // XML <claude_code_session_update>
  stateFile,
  newLastProcessedIndex: messages.length - 1,
  cwd: hookInput.cwd,
  sdkToolsMode,
};
fs.writeFileSync(payloadFile, JSON.stringify(payload));
spawnSilentWorker(path.join(__dirname, 'send_worker_sdk.ts'), payloadFile, hookInput.cwd);

```

After processing, the worker updates the persistent sync state in `session-<id>.json` to track the last processed transcript entry, ensuring subsequent hooks only process new messages.

### Pre-Tool Sync Hook: Injecting Replies into Claude Code

The [`scripts/pretool_sync.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/scripts/pretool_sync.ts) script completes the round-trip by pulling Letta replies from the conversation and injecting them back into Claude Code's context. It uses `conversation_utils.lookupConversation` to retrieve the conversation ID, then fetches new messages via the Letta API.

The script supports two injection modes:

- **Whisper mode**: Prints the agent's memory blocks and a short header on stdout using `formatAllBlocksForStdout`
- **Full mode**: Updates the `<letta>` XML section of [`CLAUDE.md`](https://github.com/letta-ai/claude-subconscious/blob/main/CLAUDE.md) using `formatMemoryBlocksAsXml` and `updateClaudeMd`

Both modes rely on the conversation ID stored during the SessionStart hook, maintaining continuity across the asynchronous dialogue.

## Background Worker and SDK Integration

The actual transmission to Letta happens asynchronously via [`scripts/send_worker_sdk.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/scripts/send_worker_sdk.ts), which runs as a detached background process to avoid blocking the Claude Code UI.

### Letta Code SDK Session Management

Lines 73-85 of [`send_worker_sdk.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/send_worker_sdk.ts) create a Letta Code SDK session with appropriate tool restrictions based on the `sdkToolsMode` parameter:

```typescript
// scripts/send_worker_sdk.ts – SDK session with tool restrictions
const session = resumeSession(payload.conversationId, sessionOptions);
await session.send(payload.message);
for await (const msg of session.stream()) {
  // log assistant chunks, tool calls, etc.
}
session.close();

```

The `resumeSession` function initializes the connection with the restrictions specified (read-only, full, or off), allowing the Subconscious agent to execute client-side tools when permitted. The SDK forwards the XML payload to Letta, where the agent processes the update and stores its response in the conversation for the next Pre-Tool Sync hook to retrieve.

## XML Message Protocol

Claude Subconscious uses a structured XML protocol to communicate state changes:

- **`<claude_code_session_start>`**: Contains project metadata, path, session ID, timestamp, and SDK tools mode. Sent once when the session initializes.
- **`<claude_code_session_update>`**: Contains the formatted transcript entries since the last sync. Sent after each user prompt.
- **`<letta_message>`**: Wraps the Subconscious agent's responses when injected back into Claude Code.

This XML structure allows the Letta agent to parse session context, distinguish between initialization and incremental updates, and maintain awareness of the current tool permissions.

## Configuration and Environment Setup

To enable two-way communication, set these environment variables before running Claude Code:

```bash
export LETTA_API_KEY=your-letta-api-key
export LETTA_AGENT_ID=your-agent-id          # Optional – auto-imported if missing

export LETTA_MODE=full                      # whisper | full | off

export LETTA_SDK_TOOLS=read-only           # read-only | full | off

```

When Claude Code runs with the Subconscious plugin installed, it automatically invokes the three hook scripts based on the plugin manifest. No additional code is required to initiate the two-way channel.

## Inspecting the Conversation State

The durable conversation mapping lives in `<project>/.letta/claude/conversations.json`. You can inspect this file to verify the connection:

```typescript
import { getConversationsFile } from './conversation_utils.js';
import * as fs from 'fs';
const mapPath = getConversationsFile(process.cwd());
const map = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
console.log('Current conversations:', map);

```

Additionally, individual session state is tracked in `session-<id>.json` files, which record the `lastProcessedIndex` to prevent duplicate message processing.

## Summary

- **Three-hook architecture**: [`session_start.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/session_start.ts) establishes the connection, [`send_messages_to_letta.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/send_messages_to_letta.ts) pushes updates, and [`pretool_sync.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/pretool_sync.ts) pulls responses.
- **Persistent state**: Conversation IDs are stored in [`conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/conversations.json) and session progress in `session-<id>.json`.
- **XML protocol**: Structured messages (`<claude_code_session_start>`, `<claude_code_session_update>`) carry metadata and transcript data.
- **Background processing**: The [`send_worker_sdk.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/send_worker_sdk.ts) worker uses the Letta Code SDK to transmit updates asynchronously without blocking Claude Code.
- **Dual injection modes**: The Pre-Tool Sync hook returns agent replies via stdout ("whisper") or by updating [`CLAUDE.md`](https://github.com/letta-ai/claude-subconscious/blob/main/CLAUDE.md) ("full").

## Frequently Asked Questions

### How does Claude Subconscious maintain conversation continuity across Claude Code restarts?

The [`session_start.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/session_start.ts) script checks [`conversations.json`](https://github.com/letta-ai/claude-subconscious/blob/main/conversations.json) for an existing mapping between the project path and Letta conversation ID. If found, it reuses that conversation; otherwise, it creates a new one. This durable mapping ensures the Subconscious retains context even when Claude Code sessions terminate and restart.

### What is the difference between "whisper" and "full" modes in two-way communication?

**Whisper mode** prints Letta agent responses directly to stdout during the Pre-Tool Sync hook, providing lightweight, ephemeral feedback. **Full mode** persists the agent's memory blocks into the [`CLAUDE.md`](https://github.com/letta-ai/claude-subconscious/blob/main/CLAUDE.md) file as XML, making the Subconscious context available to Claude Code's main reasoning loop and enabling deeper integration with the ongoing development task.

### Can the Subconscious execute code in Claude Code during two-way communication?

Yes, but only when `LETTA_SDK_TOOLS` is set to `full` or `read-only`. The [`send_worker_sdk.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/send_worker_sdk.ts) background worker creates a Letta Code SDK session with tool restrictions based on this setting. In `full` mode, the agent can propose file edits and shell commands; in `read-only` mode, it can only inspect the codebase; in `off` mode, it receives updates but cannot invoke client-side tools.

### Where are the hook scripts located in the claude-subconscious repository?

The primary hook scripts reside in the `scripts/` directory: [`session_start.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/session_start.ts) handles initialization, [`send_messages_to_letta.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/send_messages_to_letta.ts) processes transcript updates, [`send_worker_sdk.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/send_worker_sdk.ts) manages the background SDK connection, and [`pretool_sync.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/pretool_sync.ts) retrieves agent responses. Utility modules including [`conversation_utils.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/conversation_utils.ts) and [`transcript_utils.ts`](https://github.com/letta-ai/claude-subconscious/blob/main/transcript_utils.ts) support the durable state management and message formatting required for two-way communication.