Async Hook Pattern in Claude Code Stop Hooks: How It Prevents Blocking
The async hook pattern prevents Claude Code from freezing by reading input asynchronously, spawning a detached background worker process to handle heavy I/O operations, and exiting the hook immediately so the UI remains responsive.
Claude Code executes user-generated Stop hooks after every code generation step, and the letta-ai/claude-subconscious repository implements an async hook pattern that ensures these hooks never block the main execution thread. By decoupling heavy networking tasks from the hook's lifecycle, the UI remains fluid even when performing long-running operations like sending messages to the Letta service.
How the Async Hook Pattern Works
Asynchronous Input Consumption
In scripts/send_messages_to_letta.ts, the hook begins by reading the JSON payload from stdin using the readHookInput async function (lines 81‑100). This asynchronous approach allows the Node.js event loop to continue processing other tasks while waiting for the input stream to complete, preventing the initial read from becoming a blocking operation.
Detached Background Worker Architecture
Once the hook gathers the necessary data such as the transcript and conversation ID, it delegates heavy I/O operations to a separate process. The spawnSilentWorker utility in scripts/conversation_utils.ts (lines 22‑78) creates a detached child process via child.unref(), ensuring the worker survives parent termination and runs independently of Claude Code's main thread. On Windows, this optionally leverages silent‑launcher.exe to hide console windows while maintaining the detached state.
Immediate Process Termination
After launching the background worker, the hook exits immediately. As shown in send_messages_to_letta.ts at line 333, the process logs completion and terminates, returning control to Claude Code instantly. The heavy lifting—network calls to Letta, SDK messaging, and state file updates—occurs within scripts/send_worker_sdk.ts (lines 11‑55), which executes asynchronously in its own process without impacting the editor's responsiveness.
Implementation Example
Minimal Async Stop Hook Skeleton
#!/usr/bin/env npx tsx
import * as path from 'path';
import { spawnSilentWorker } from './conversation_utils.js';
async function readInput(): Promise<any> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => resolve(JSON.parse(data)));
process.stdin.on('error', reject);
});
}
async function main() {
const input = await readInput();
const payloadFile = '/tmp/payload.json';
// Write payload preparation logic here
const worker = spawnSilentWorker(
path.join(__dirname, 'my_worker.ts'),
payloadFile,
process.cwd()
);
console.log('Hook done, worker PID', worker.pid);
process.exit(0);
}
main();
Background Worker Implementation
The worker script handles the actual processing:
#!/usr/bin/env npx tsx
import * as fs from 'fs';
const payloadPath = process.argv[2];
const payload = JSON.parse(fs.readFileSync(payloadPath, 'utf-8'));
async function doWork() {
// Perform long-running operations: HTTP requests, SDK calls, etc.
}
doWork().finally(() => fs.unlinkSync(payloadPath));
This pattern mirrors the production implementation in send_worker_sdk.ts, where the worker parses the payload, initializes the Letta SDK session, and updates state files without blocking the parent hook.
Summary
- The async hook pattern reads input asynchronously via
readHookInputinscripts/send_messages_to_letta.tsto avoid blocking on I/O during initialization. - Detached processes created by
spawnSilentWorkerinscripts/conversation_utils.ts(lines 22‑78) execute heavy tasks independently usingchild.unref(). - Immediate exit after worker spawning ensures Claude Code resumes its workflow instantly, with background workers handling network latency and state persistence.
- The architecture separates the hook orchestration in
send_messages_to_letta.tsfrom task execution insend_worker_sdk.ts, maintaining UI fluidity regardless of backend operation duration.
Frequently Asked Questions
What is the async hook pattern in Claude Code Stop hooks?
The async hook pattern is an architectural approach where Stop hooks read input asynchronously, spawn detached background workers to handle resource-intensive operations, and terminate immediately to return control to Claude Code. As implemented in the letta-ai/claude-subconscious repository, this pattern ensures that network calls and SDK operations never freeze the editor interface.
How does spawnSilentWorker prevent blocking?
The spawnSilentWorker function in scripts/conversation_utils.ts (lines 22‑78) creates a detached child process using Node.js spawn with detached: true and calls child.unref(). This decouples the worker's lifecycle from the parent hook, allowing Claude Code to continue execution while the worker performs time-consuming tasks like HTTP requests to the Letta service.
Why use a detached process instead of async/await in the main hook?
While async/await prevents blocking the JavaScript event loop, it does not protect against process-level latency. Claude Code waits for the entire hook process to exit before continuing. By spawning a detached worker and exiting immediately, the hook signals completion to Claude Code within milliseconds, while the heavy lifting continues in a separate process that can tolerate longer execution times without impacting the UI.
Where does the heavy processing occur in the letta-ai/claude-subconscious implementation?
All network-bound operations occur in scripts/send_worker_sdk.ts (lines 11‑55), which runs as a detached background worker. This script handles payload parsing, Letta SDK session initialization, message transmission, and state file updates. The main hook in send_messages_to_letta.ts only prepares the payload and launches the worker, ensuring zero blocking time in the critical path.
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 →