How Claude Subconscious Integrates with Claude Code Hooks: SessionStart, PreToolUse, UserPromptSubmit, and Stop
Claude Subconscious integrates with Claude Code hooks by registering TypeScript scripts in hooks/hooks.json that map SessionStart, PreToolUse, UserPromptSubmit, and Stop events to specific handlers, enabling bidirectional synchronization between Claude Code sessions and Letta agent conversations.
The letta-ai/claude-subconscious repository implements a Claude Code plugin that creates a persistent bridge between Claude Code and Letta AI agents. By leveraging Claude Code's hook system, the plugin maintains stateful memory synchronization across the entire development session lifecycle.
Hook Registration in hooks.json
Claude Code discovers plugins through a declarative configuration file. In hooks/hooks.json, each event maps to a command executed via the silent-npx.cjs wrapper, which suppresses console windows on Windows systems.
The four hook events are registered as follows:
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.ts\"",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/pretool_sync.ts\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
"timeout": 10
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/send_messages_to_letta.ts\"",
"timeout": 120,
"async": true
}
]
}
]
}
}
Each hook receives a JSON payload via stdin containing metadata like session_id, cwd, and hook_event_name.
SessionStart: Initializing Letta Conversations
When Claude Subconscious integrates with the SessionStart hook, it establishes a new Letta conversation for the development session. The handler in scripts/session_start.ts performs several initialization steps:
- Reads the hook input to extract
session_idandcwd - Calls
conversation_utils.getOrCreateConversationto provision a Letta conversation - Persists the mapping in
<cwd>/.letta/claude/conversations.json - Sends an introductory
<claude_code_session_start>XML message viasendSessionStartMessage
The script generates structured XML that includes SDK-tools mode, project name, and session metadata:
<claude_code_session_start>
<project>my-project</project>
<path>/home/user/my-project</path>
<session_id>c8f9e3b2-1a4d-4b5e-9d6f-1234567890ab</session_id>
<timestamp>2026-03-26T14:02:33.123Z</timestamp>
<sdk_tools_mode>read-only</sdk_tools_mode>
<context>
A new Claude Code session has begun. I'll be sending you updates as the session progresses.
Tool access: Read-only tool access — you can Read, Grep, Glob files and search the web.
</context>
</claude_code_session_start>
This initialization ensures every Claude Code session has a corresponding Letta agent conversation with durable state stored in the .letta/claude/ directory.
PreToolUse: Injecting Context Before Tool Execution
The PreToolUse hook in scripts/pretool_sync.ts intercepts tool calls to surface Letta agent guidance within Claude Code's context. Before Claude executes any tool, this handler:
- Loads the per-session sync state via
loadSyncState - Fetches new assistant messages from Letta using
fetchNewMessages - Detects memory block changes with
detectChangedBlocks - Returns a JSON object with an
additionalContextfield
Claude Code automatically merges this additionalContext into the next tool-use prompt. The JSON output structure follows this pattern:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": "<letta_message from=\"Subconscious\" timestamp=\"2026-03-26T14:05:10Z\">\nRemember to run `npm test` after the refactor.\n</letta_message>\n\n<letta_memory_update>\n<review_status status=\"modified\">\n- TODO: add unit tests\n+ TODO: add integration tests\n</review_status>\n</letta_memory_update>\n\n<instruction>Your Subconscious (Subconscious) just sent a message above. Briefly acknowledge what Subconscious said in your next response - just a short note like \"Sub notes: …\" so the user knows.</instruction>"
}
}
This mechanism allows the Letta agent to inject real-time guidance, memory updates, and contextual reminders directly into Claude Code's tool execution flow.
UserPromptSubmit: Merging Memory into Prompts
The UserPromptSubmit hook (scripts/sync_letta_memory.ts) activates when the user finishes typing a prompt, enabling Claude Subconscious to integrate Letta memory updates before Claude processes the input. This handler supports two modes:
Whisper mode (default): Emits only new assistant messages to stdout
Full mode: Also updates .claude/CLAUDE.md with complete memory blocks
The script executes these operations:
- Loads sync state and retrieves the Letta conversation ID
- Fetches latest agent data via
fetchAgent - Retrieves new messages using
fetchAssistantMessages - Detects changed blocks and formats diff-style XML via
formatChangedBlocksForStdout - Emits XML to stdout, which Claude Code captures and prefixes to the user prompt
In whisper mode, the stdout output appears as:
<letta_message from="Subconscious" timestamp="2026-03-26T14:07:45Z">
You might want to rename `fooBar` to `barFoo` for consistency.
</letta_message>
<instruction>Your Subconscious (Subconscious) sent you a message above. Briefly acknowledge what Subconscious said - just a short note like "Sub notes: …" so the user knows.</instruction>
In full mode, the script additionally calls updateClaudeMd to persist memory blocks to the project's .claude/CLAUDE.md file, ensuring persistent context across sessions.
Stop: Persisting Transcripts to Letta
When the session ends, the Stop hook (scripts/send_messages_to_letta.ts) ensures the complete conversation history reaches the Letta agent. This handler:
- Reads the transcript JSONL file generated by Claude Code
- Formats entries as
<message role="...">...</message>XML within a<claude_code_session_update>envelope - Spawns a detached background worker via
spawnSilentWorkerrunningscripts/send_worker_sdk.ts - Updates the per-session sync state to track the last processed transcript index
The background worker (send_worker_sdk.ts) loads the Letta Code SDK (@letta-ai/letta-code-sdk) and resumes the conversation:
// Executed in send_worker_sdk.ts
session.resumeSession(...).send(payload.message)
This asynchronous processing allows the Stop hook to return immediately while the worker streams the assistant's response and handles large transcript transfers without blocking Claude Code's shutdown.
State Management and API Utilities
Claude Subconscious relies on durable state persistence in <cwd>/.letta/claude/:
conversations.json: Mapssession_idto{conversationId, agentId}session-<id>.json: StoreslastProcessedIndex,lastSeenMessageId, and memory block snapshots
The scripts/conversation_utils.ts module provides shared utilities including createConversation, lookupConversation, loadSyncState, saveSyncState, and XML escaping functions. URL construction is handled by scripts/letta_api_url.ts, which exports buildLettaApiUrl and normalizes the LETTA_API_BASE environment variable.
Summary
- Claude Subconscious integrates with Claude Code hooks through declarative registration in
hooks/hooks.json, mapping SessionStart, PreToolUse, UserPromptSubmit, and Stop events to specific TypeScript handlers. - SessionStart initializes Letta conversations and persists session mappings in
.letta/claude/conversations.json. - PreToolUse returns JSON with
additionalContextcontaining XML-formatted Letta messages and memory updates that Claude Code injects into tool prompts. - UserPromptSubmit writes XML to stdout (whisper mode) or updates
CLAUDE.md(full mode) to merge Letta guidance before Claude processes user input. - Stop spawns a background SDK worker to send the complete transcript to Letta without blocking session termination.
- All handlers rely on
conversation_utils.tsfor state persistence andletta_api_url.tsfor API endpoint construction.
Frequently Asked Questions
How does Claude Code know which scripts to run for each hook event?
Claude Code reads the hooks/hooks.json file in the plugin root directory, which declares event matchers and associated commands. Each entry specifies the script path, timeout, and whether execution should be asynchronous. Claude Subconscious uses the silent-npx.cjs wrapper to execute TypeScript files via tsx without spawning visible console windows.
What is the difference between PreToolUse and UserPromptSubmit hooks?
PreToolUse runs before Claude executes any tool, returning JSON that Claude Code merges as additionalContext into the tool-use prompt. UserPromptSubmit runs after the user types a message but before Claude processes it, writing XML directly to stdout that Claude Code prefixes to the user's prompt. PreToolUse is tool-centric, while UserPromptSubmit is prompt-centric.
Where does Claude Subconscious store session state between hook invocations?
The plugin stores durable state in a .letta/claude/ directory within the current working directory. The conversations.json file maintains the mapping between Claude Code session IDs and Letta conversation IDs, while individual session-<id>.json files track synchronization state including the last processed message index and memory block snapshots.
Why does the Stop hook use a background worker process?
The Stop hook spawns send_worker_sdk.ts as a detached background worker via spawnSilentWorker because transmitting the full conversation transcript to Letta may take significant time. Running asynchronously prevents the hook from blocking Claude Code's session termination while ensuring the Letta agent receives the complete message history for long-term memory consolidation.
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 →