feat: add Letta Code SDK transport for client-side tool access (#19)

Give the Subconscious agent client-side tool access (Read, Grep, Glob,
web_search) via the Letta Code SDK. Instead of being limited to memory
operations, Sub can now read files and search the web while processing
transcripts.

Architecture:
- New send_worker_sdk.ts uses resumeSession() from @letta-ai/letta-code-sdk
- send_messages_to_letta.ts routes to SDK or legacy worker based on
  LETTA_SDK_TOOLS env var (read-only | full | off)
- Stop hook is now async (won't block Claude Code)
- Legacy raw API path preserved for LETTA_SDK_TOOLS=off

New env var: LETTA_SDK_TOOLS
- read-only (default): Read, Grep, Glob, web_search, fetch_webpage
- full: all tools
- off: legacy memory-only behavior

Closes #19

Written by Cameron ◯ Letta Code

"The best interface is no interface." - Golden Krishna
This commit is contained in:
Cameron
2026-03-13 16:07:42 -07:00
parent 9af486be68
commit 13b454bf00
7 changed files with 3555 additions and 87 deletions

View File

@@ -112,6 +112,7 @@ export LETTA_MODEL="anthropic/claude-sonnet-4-5" # Model override
export LETTA_CONTEXT_WINDOW="1048576" # Context window size (e.g. 1M tokens)
export LETTA_HOME="$HOME" # Consolidate .letta state to ~/.letta/
export LETTA_CHECKPOINT_MODE="blocking" # Or "async", "off"
export LETTA_SDK_TOOLS="read-only" # Or "full", "off"
```
- `LETTA_MODE` - Controls what gets injected. `whisper` (default, messages only), `full` (blocks + messages), `off` (disable). See [Modes](#modes).
@@ -121,6 +122,7 @@ export LETTA_CHECKPOINT_MODE="blocking" # Or "async", "off"
- `LETTA_CONTEXT_WINDOW` - Override the agent's context window size (in tokens). Useful when `LETTA_MODEL` is set to a model with a large context window that differs from the server default. Example: `1048576` for 1M tokens.
- `LETTA_HOME` - Base directory for plugin state files. Creates `{LETTA_HOME}/.letta/claude/` for session data and conversation mappings. Defaults to current working directory. Set to `$HOME` to consolidate all state in one location.
- `LETTA_CHECKPOINT_MODE` - Controls checkpoint behavior at natural pause points (`AskUserQuestion`, `ExitPlanMode`). See [Checkpoint Hooks](#checkpoint-hooks).
- `LETTA_SDK_TOOLS` - Controls client-side tool access for the Subconscious agent. See [SDK Tools](#sdk-tools).
### Modes
@@ -303,23 +305,38 @@ Consider asking about X before proceeding...
</letta_message>
```
### SDK Tools
By default, the Subconscious agent now gets **client-side tool access** via the [Letta Code SDK](https://docs.letta.com/letta-code/sdk/). Instead of being limited to memory operations, Sub can read your files, search the web, and explore your codebase while processing transcripts.
**Configuration via `LETTA_SDK_TOOLS`:**
| Mode | Tools Available | Use Case |
|------|----------------|----------|
| `read-only` (default) | `Read`, `Grep`, `Glob`, `web_search`, `fetch_webpage` | Safe background research and file reading |
| `full` | All tools (Bash, Edit, Write, etc.) | Full autonomy — Sub can make changes |
| `off` | None (memory-only) | Legacy behavior, raw API transport |
> **Note:** Requires `@letta-ai/letta-code-sdk` (installed as a dependency). Set `LETTA_SDK_TOOLS=off` to use the legacy raw API path without the SDK.
### Stop
Uses a **fire-and-forget** pattern to avoid timeout issues:
Uses an **async hook** pattern — runs in the background without blocking Claude Code:
1. Main hook (`send_messages_to_letta.ts`) runs quickly:
- Parses the session transcript (JSONL format)
- Extracts user messages, assistant responses, thinking blocks, and tool usage
- Writes payload to a temp file
- Spawns detached background worker (`send_worker.ts`)
- Spawns detached background worker
- Exits immediately
2. Background worker runs independently:
- Sends messages to Letta agent
- **SDK mode** (`send_worker_sdk.ts`): Opens a Letta Code SDK session, giving Sub client-side tools
- **Legacy mode** (`send_worker.ts`): Sends via raw API (memory-only)
- Updates state on success
- Cleans up temp file
This ensures the hook never times out, even when the Letta API is slow.
The Stop hook runs as an async hook, so it never blocks Claude Code.
## State Management

View File

@@ -1,67 +1,68 @@
{
"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
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
"timeout": 10
}
]
}
],
"PreToolUse": [
{
"matcher": "AskUserQuestion|ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/plan_checkpoint.ts\"",
"timeout": 10
}
]
},
{
"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": 15
}
]
}
]
}
}
{
"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
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
"timeout": 10
}
]
}
],
"PreToolUse": [
{
"matcher": "AskUserQuestion|ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/plan_checkpoint.ts\"",
"timeout": 10
}
]
},
{
"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
}
]
}
]
}
}

3254
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@letta-ai/letta-code-sdk": "^0.1.0",
"tsx": "^4.7.0"
},
"devDependencies": {

View File

@@ -59,6 +59,30 @@ export function getTempStateDir(): string {
return path.join(os.tmpdir(), `letta-claude-sync-${uid}`);
}
// ============================================
// SDK Tools Configuration
// ============================================
export type SdkToolsMode = 'read-only' | 'full' | 'off';
/** Read-only tool set: safe defaults for background Sub execution */
export const SDK_TOOLS_READ_ONLY = ['Read', 'Grep', 'Glob', 'web_search', 'fetch_webpage'];
/** Tools to always block in SDK sessions (require interactive input) */
export const SDK_TOOLS_BLOCKED = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
/**
* Get the SDK tools mode from LETTA_SDK_TOOLS env var.
* - read-only (default): Sub can read files and search the web
* - full: Sub has full tool access (use with caution)
* - off: Legacy mode, no SDK — raw API only (memory-only Sub)
*/
export function getSdkToolsMode(): SdkToolsMode {
const mode = process.env.LETTA_SDK_TOOLS?.toLowerCase();
if (mode === 'full' || mode === 'off') return mode;
return 'read-only';
}
// Types
export interface SyncState {
lastProcessedIndex: number;

View File

@@ -36,6 +36,7 @@ import {
LogFn,
getMode,
getTempStateDir,
getSdkToolsMode,
} from './conversation_utils.js';
import {
readTranscript,
@@ -322,23 +323,47 @@ Write your response as if speaking directly to Claude Code.
</instructions>
</claude_code_session_update>`;
// Write payload to temp file for the worker
const payloadFile = path.join(TEMP_STATE_DIR, `payload-${hookInput.session_id}-${Date.now()}.json`);
const payload = {
apiKey,
conversationId,
sessionId: hookInput.session_id,
message: userMessage,
stateFile: getSyncStateFile(hookInput.cwd, hookInput.session_id),
newLastProcessedIndex: messages.length - 1,
};
fs.writeFileSync(payloadFile, JSON.stringify(payload), 'utf-8');
log(`Wrote payload to ${payloadFile}`);
// Decide transport: SDK (with client-side tools) or legacy (raw API)
const sdkToolsMode = getSdkToolsMode();
log(`SDK tools mode: ${sdkToolsMode}`);
// Spawn worker as detached background process
const workerScript = path.join(__dirname, 'send_worker.ts');
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
log(`Spawned background worker (PID: ${child.pid})`);
const payloadFile = path.join(TEMP_STATE_DIR, `payload-${hookInput.session_id}-${Date.now()}.json`);
const stateFile = getSyncStateFile(hookInput.cwd, hookInput.session_id);
if (sdkToolsMode !== 'off') {
// SDK mode: send via Letta Code SDK (Sub gets client-side tools)
const sdkPayload = {
agentId,
sessionId: hookInput.session_id,
message: userMessage,
stateFile,
newLastProcessedIndex: messages.length - 1,
cwd: hookInput.cwd,
sdkToolsMode,
};
fs.writeFileSync(payloadFile, JSON.stringify(sdkPayload), 'utf-8');
log(`Wrote SDK payload to ${payloadFile}`);
const workerScript = path.join(__dirname, 'send_worker_sdk.ts');
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
log(`Spawned SDK worker (PID: ${child.pid})`);
} else {
// Legacy mode: send via raw API (memory-only Sub)
const legacyPayload = {
apiKey,
conversationId,
sessionId: hookInput.session_id,
message: userMessage,
stateFile,
newLastProcessedIndex: messages.length - 1,
};
fs.writeFileSync(payloadFile, JSON.stringify(legacyPayload), 'utf-8');
log(`Wrote legacy payload to ${payloadFile}`);
const workerScript = path.join(__dirname, 'send_worker.ts');
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
log(`Spawned legacy worker (PID: ${child.pid})`);
}
log('Hook completed (worker running in background)');

146
scripts/send_worker_sdk.ts Normal file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env npx tsx
/**
* SDK-based background worker that sends messages to Letta via Letta Code SDK.
* Gives the Subconscious agent client-side tool access (Read, Grep, Glob, etc.).
*
* Spawned by send_messages_to_letta.ts as a detached process.
* Falls back gracefully if the SDK is not available.
*
* Usage: npx tsx send_worker_sdk.ts <payload_file>
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const uid = typeof process.getuid === 'function' ? process.getuid() : process.pid;
const TEMP_STATE_DIR = path.join(os.tmpdir(), `letta-claude-sync-${uid}`);
const LOG_FILE = path.join(TEMP_STATE_DIR, 'send_worker_sdk.log');
interface SdkPayload {
agentId: string;
sessionId: string;
message: string;
stateFile: string;
newLastProcessedIndex: number;
cwd: string;
sdkToolsMode: 'read-only' | 'full';
}
function log(message: string): void {
const dir = path.dirname(LOG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const timestamp = new Date().toISOString();
fs.appendFileSync(LOG_FILE, `[${timestamp}] ${message}\n`);
}
async function sendViaSdk(payload: SdkPayload): Promise<boolean> {
log(`Loading Letta Code SDK...`);
// Dynamic import so this file can be parsed even if SDK isn't installed
const { resumeSession } = await import('@letta-ai/letta-code-sdk');
// Configure tool restrictions based on mode
const readOnlyTools = ['Read', 'Grep', 'Glob', 'web_search', 'fetch_webpage'];
const blockedTools = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
const sessionOptions: Record<string, unknown> = {
disallowedTools: blockedTools,
permissionMode: 'bypassPermissions',
cwd: payload.cwd,
skillSources: [], // Sub doesn't need skills
systemInfoReminder: false, // reduce noise
sleeptime: { trigger: 'off' }, // don't recurse sleeptime
};
if (payload.sdkToolsMode === 'read-only') {
sessionOptions.allowedTools = readOnlyTools;
}
// 'full' mode: no allowedTools restriction (all tools available)
log(`Creating SDK session for agent ${payload.agentId} (mode: ${payload.sdkToolsMode})`);
log(` cwd: ${payload.cwd}`);
log(` allowedTools: ${payload.sdkToolsMode === 'read-only' ? readOnlyTools.join(', ') : 'all'}`);
const session = resumeSession(payload.agentId, sessionOptions);
try {
log(`Sending message (${payload.message.length} chars)...`);
await session.send(payload.message);
// Stream and capture the response
let assistantResponse = '';
let messageCount = 0;
for await (const msg of session.stream()) {
messageCount++;
if (msg.type === 'assistant' && msg.content) {
assistantResponse += msg.content;
log(` Assistant chunk: ${msg.content.substring(0, 100)}...`);
}
}
log(`Stream complete: ${messageCount} messages, assistant response: ${assistantResponse.length} chars`);
// The SDK session sends the message to the Letta agent which processes it
// and generates a response. The response is automatically stored in the
// agent's conversation history on the Letta server. The existing
// pretool_sync / sync_letta_memory flow will pick it up and inject it
// into Claude's context on the next prompt.
return true;
} finally {
session.close();
log('SDK session closed');
}
}
async function main(): Promise<void> {
const payloadFile = process.argv[2];
if (!payloadFile) {
log('ERROR: No payload file specified');
process.exit(1);
}
log('='.repeat(60));
log(`SDK Worker started with payload: ${payloadFile}`);
try {
if (!fs.existsSync(payloadFile)) {
log(`ERROR: Payload file not found: ${payloadFile}`);
process.exit(1);
}
const payload: SdkPayload = JSON.parse(fs.readFileSync(payloadFile, 'utf-8'));
log(`Loaded payload for session ${payload.sessionId}`);
const success = await sendViaSdk(payload);
if (success) {
// Update state file
const state = JSON.parse(fs.readFileSync(payload.stateFile, 'utf-8'));
state.lastProcessedIndex = payload.newLastProcessedIndex;
fs.writeFileSync(payload.stateFile, JSON.stringify(state, null, 2));
log(`Updated state: lastProcessedIndex=${payload.newLastProcessedIndex}`);
}
// Clean up payload file
fs.unlinkSync(payloadFile);
log('Cleaned up payload file');
log('SDK Worker completed successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log(`ERROR: ${errorMessage}`);
if (error instanceof Error && error.stack) {
log(`Stack: ${error.stack}`);
}
process.exit(1);
}
}
main();