Skip to content
Agent Memory
Hook

Hooks

What letta-code runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
letta-code
3.8k26 skills8 hooks

Claude Code

$ npx -y skills add letta-ai/letta-code --agent claude-code

Where it lives

  • hooks/block-rm-rf.shGitHub
    Read the script
    #!/bin/bash
    # Block dangerous rm -rf commands
    
    input=$(cat)
    tool_name=$(echo "$input" | jq -r '.tool_name')
    
    # Only check Bash commands
    if [ "$tool_name" != "Bash" ]; then
      exit 0
    fi
    
    command=$(echo "$input" | jq -r '.tool_input.command')
    
    # Check for rm -rf pattern (handles -rf, -fr, -rfi, etc.)
    if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)'; then
      echo "Blocked: rm -rf commands must be ran manually, use rm and rmdir instead." >&2
      exit 2
    fi
    
    exit 0
    
  • hooks/desktop-notification.shGitHub
    Read the script
    #!/bin/bash
    # Send desktop notification using osascript (macOS)
    
    input=$(cat)
    message=$(echo "$input" | jq -r '.message')
    level=$(echo "$input" | jq -r '.level')
    
    # Display the notification (show subtitle only for warning/error)
    if [ "$level" = "error" ]; then
      osascript -e "display notification \"$message\" with title \"Letta Code\" subtitle \"Error\""
    elif [ "$level" = "warning" ]; then
      osascript -e "display notification \"$message\" with title \"Letta Code\" subtitle \"Warning\""
    else
      osascript -e "display notification \"$message\" with title \"Letta Code\""
    fi
    
    exit 0
    
  • hooks/fix-on-changes.shGitHub
    Read the script
    #!/bin/bash
    # Hook script: Run bun run fix if there are uncommitted changes
    # Triggered on: Stop event
    
    # Check if there are any uncommitted changes (staged or unstaged)
    if git diff --quiet HEAD 2>/dev/null; then
        echo "No changes, skipping."
        exit 0
    fi
    
    # Run fix - capture output and send to stderr on failure
    output=$(bun run fix 2>&1)
    exit_code=$?
    
    if [ $exit_code -eq 0 ]; then
        echo "$output"
        exit 0
    else
        echo "$output" >&2
        exit 2
    fi
    
  • hooks/get-api-key.tsGitHub
    Read the script
    #!/usr/bin/env bun
    // Helper script to get API key from keychain using Bun's secrets API
    // Used by memory_logger.py to avoid separate keychain authorization
    
    const SERVICE_NAME = "letta-code";
    const API_KEY_NAME = "letta-api-key";
    
    try {
      const apiKey = await Bun.secrets.get({
        service: SERVICE_NAME,
        name: API_KEY_NAME,
      });
      if (apiKey) {
        process.stdout.write(apiKey);
      }
    } catch {
      // Silent failure - Python will try other sources
    }
    
  • hooks/memory_logger.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """
    Memory Logger Hook - Tracks memory block changes with git-style diffs.
    
    Structure:
      .letta/memory_logs/
        human.json       # Current state from server
        human.jsonl      # Log of diffs (git-style patches)
        persona.json
        persona.jsonl
        ...
    
    Hook: Fetches all memory blocks, compares to local state, logs diffs.
    CLI:
      list              - Show all memory blocks
      show <name>       - Show current contents of a block
      history <name>    - Interactive diff navigation
    """
    
    import json
    import os
    import re
    import sys
    import difflib
    from datetime import datetime, timezone
    from pathlib import Path
    from typing import Optional
    import urllib.request
    import urllib.error
    
    
    # =============================================================================
    # Configuration
    # =============================================================================
    
    def get_logs_dir(working_dir: Optional[str] = None) -> Path:
        """Get the memory logs directory."""
        if working_dir:
            return Path(working_dir) / ".letta" / "memory_logs"
        # For CLI usage, look relative to the script's parent directory (project root)
        # since the script lives in /hooks/memory_logger.py
        script_dir = Path(__file__).parent
        project_root = script_dir.parent
        return project_root / ".letta" / "memory_logs"
    
    
    def get_letta_settings() -> dict:
        """Read Letta settings from ~/.letta/settings.json."""
        settings_path = Path.home() / ".letta" / "settings.json"
        if settings_path.exists():
            try:
                return json.loads(settings_path.read_text())
            except (json.JSONDecodeError, IOError):
                pass
        return {}
    
    
    def get_api_key_from_keychain() -> Optional[str]:
        """Get the Letta API key from macOS keychain via Bun helper."""
        import subprocess
    
        # Use Bun helper script (uses Bun's existing keychain access)
        hooks_dir = Path(__file__).parent
        helper_script = hooks_dir / "get-api-key.ts"
    
        if helper_script.exists():
            try:
                result = subprocess.run(
                    ["bun", str(helper_script)],
                    capture_output=True,
                    text=True,
                    timeout=5,
                )
                if result.returncode == 0 and result.stdout.strip():
                    return result.stdout.strip()
            except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
                pass
    
        return None
    
    
    def get_api_key() -> Optional[str]:
        """Get the Letta API key from keychain, environment, or settings."""
        # Try macOS keychain first
        api_key = get_api_key_from_keychain()
        if api_key:
            return api_key
    
        # Fall back to environment variable
        api_key = os.environ.get("LETTA_API_KEY")
        if api_key:
            return api_key
    
        # Fall back to settings file
        settings = get_letta_settings()
        env_settings = settings.get("env", {})
        return env_settings.get("LETTA_API_KEY")
    
    
    def get_base_url() -> str:
        """Get the Letta API base URL."""
        base_url = os.environ.get("LETTA_BASE_URL")
        if base_url:
            return base_url.rstrip("/")
        settings = get_letta_settings()
        env_settings = settings.get("env", {})
        return env_settings.get("LETTA_BASE_URL", "https://api.letta.com").rstrip("/")
    
    
    # =============================================================================
    # Letta API
    # =============================================================================
    
    def fetch_all_memory_blocks(agent_id: str, verbose: bool = False) -> list[dict]:
        """Fetch all memory blocks for an agent from the Letta API."""
        api_key = get_api_key()
        base_url = get_base_url()
    
        if not api_key:
            if verbose:
                print("  ERROR: No API key available")
            return []
    
        url = f"{base_url}/v1/agents/{agent_id}/core-memory/blocks"
    
        if verbose:
            print(f"  URL: {url}")
    
        headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        }
    
        try:
            req = urllib.request.Request(url, headers=headers, method="GET")
            with urllib.request.urlopen(req, timeout=10) as response:
                data = json.loads(response.read().decode("utf-8"))
                if verbose:
                    print(f"  Response type: {type(data).__name__}")
                return data if isinstance(data, list) else []
        except urllib.error.HTTPError as e:
            if verbose:
                print(f"  HTTP Error: {e.code} {e.reason}")
                try:
                    body = e.read().decode("utf-8")
                    print(f"  Response: {body[:200]}")
                except Exception:
                    pass
            return []
        except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as e:
            if verbose:
                print(f"  Error: {type(e).__name__}: {e}")
            return []
        except Exception as e:
            if verbose:
                print(f"  Unexpected error: {type(e).__name__}: {e}")
            return []
    
    
    # =============================================================================
    # Diff Operations
    # =============================================================================
    
    def create_unified_diff(old_content: str, new_content: str, block_name: str) -> str:
        """Create a unified diff between old and new content."""
        old_lines = old_content.splitlines(keepends=True)
        new_lines = new_content.splitlines(keepends=True)
    
        # Ensure trailing newlines for proper diff
        if old_lines and not old_lines[-1].endswith('\n'):
            old_lines[-1] += '\n'
        if new_lines and not new_lines[-1].endswith('\n'):
            new_lines[-1] += '\n'
    
        diff = difflib.unified_diff(
            old_lines,
            new_lines,
            fromfile=f"a/{block_name}",
            tofile=f"b/{block_name}",
        )
        return "".join(diff)
    
    
    def apply_diff(content: str, diff_text: str, reverse: bool = False) -> str:
        """Apply or reverse a unified diff (pure Python implementation)."""
        lines = content.splitlines()
        diff_lines = diff_text.splitlines()
    
        # Parse hunks from d
  • hooks/permissions-status.shGitHub
    Read the script
    #!/bin/bash
    # Show current permission status when a permission request is made
    
    input=$(cat)
    tool_name=$(echo "$input" | jq -r '.tool_name')
    working_dir=$(echo "$input" | jq -r '.working_directory')
    
    # Colors for output
    BOLD='\033[1m'
    DIM='\033[2m'
    GREEN='\033[32m'
    RED='\033[31m'
    YELLOW='\033[33m'
    BLUE='\033[34m'
    RESET='\033[0m'
    
    echo -e "\n"
    echo -e "${BOLD}═══════════════════════════════════════════════════════════════${RESET}"
    echo -e "${BOLD}Permission Request: ${BLUE}$tool_name${RESET}"
    echo -e "${BOLD}═══════════════════════════════════════════════════════════════${RESET}\n"
    
    # Function to display permissions from a file
    show_permissions() {
      local file="$1"
      local label="$2"
      local color="$3"
    
      echo -e "${color}${BOLD}$label${RESET}"
      echo -e "${DIM}$file${RESET}"
    
      if [ -f "$file" ]; then
        local allow=$(jq -r '.permissions.allow // [] | .[]' "$file" 2>/dev/null)
        local deny=$(jq -r '.permissions.deny // [] | .[]' "$file" 2>/dev/null)
        local ask=$(jq -r '.permissions.ask // [] | .[]' "$file" 2>/dev/null)
    
        if [ -n "$allow" ] || [ -n "$deny" ] || [ -n "$ask" ]; then
          if [ -n "$allow" ]; then
            echo -e "  ${GREEN}Allow:${RESET}"
            echo "$allow" | while read -r rule; do
              [ -n "$rule" ] && echo -e "    ${GREEN}✓${RESET} $rule"
            done
          fi
    
          if [ -n "$deny" ]; then
            echo -e "  ${RED}Deny:${RESET}"
            echo "$deny" | while read -r rule; do
              [ -n "$rule" ] && echo -e "    ${RED}✗${RESET} $rule"
            done
          fi
    
          if [ -n "$ask" ]; then
            echo -e "  ${YELLOW}Ask:${RESET}"
            echo "$ask" | while read -r rule; do
              [ -n "$rule" ] && echo -e "    ${YELLOW}?${RESET} $rule"
            done
          fi
        else
          echo -e "  ${DIM}(none)${RESET}"
        fi
      else
        echo -e "  ${DIM}(file not found)${RESET}"
      fi
      echo ""
    }
    
    # XDG config settings (~/.config/letta/settings.json)
    xdg_config="${XDG_CONFIG_HOME:-$HOME/.config}"
    show_permissions "$xdg_config/letta/settings.json" "User Settings (XDG)" "$BLUE"
    
    # Legacy global settings (~/.letta/settings.json)
    show_permissions "$HOME/.letta/settings.json" "User Settings (Legacy)" "$BLUE"
    
    # Project settings (.letta/settings.json)
    show_permissions "$working_dir/.letta/settings.json" "Project Settings" "$YELLOW"
    
    # Project local settings (.letta/settings.local.json)
    show_permissions "$working_dir/.letta/settings.local.json" "Project Local Settings" "$GREEN"
    
    echo -e "${BOLD}═══════════════════════════════════════════════════════════════${RESET}\n"
    
    # Exit with code 1 to continue to normal permission flow (don't auto-allow/deny)
    exit 1
    
  • hooks/prompt-instructions.shGitHub
  • hooks/typecheck-on-changes.shGitHub

All 8 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

@letta-ai
Ships withletta-code

Letta Code is a stateful agent harness for creating agents that are more like people than tools. Letta Code agents have memory, identity, and a sense of experience over time.

Stars on GitHub

Momentum

stars in total
3.8k
Star history of letta-ai/letta-code on GitHub, a chart by star-history.com

More plugins that ship hooks.

mempalace

mempalace

Local-first memory for Claude Code. It stores conversations verbatim in a private palace you can search before answering about past work, people, projects, or decisions, with optional shared-brain hub setup.

3 skills+361k
@mempalace@mempalace
Install Plugin
mempalace

mempalace

Gives Claude Code a private memory that stays on your machine. It stores past conversations word for word, so you can ask what was decided, what happened before, or who was involved and get the original context back. It can also hand tasks to other agents.

3 skills+360k
@milla-jovovich@milla-jovovich
Install Plugin
mksglu-context-mode

mksglu-context-mode

Keeps large tool outputs, logs, and files out of Claude Code's context window by processing them in a sandbox and indexing them for focused search. Includes diagnostics and a knowledge base indexer.

9 skills+233k
@mksglu@mksglu
Install Plugin
context-mode

context-mode

Keeps large command outputs, logs and data files out of your AI assistant's context by running processing in a sandbox and returning only the relevant parts. You also get a searchable index of local files and diagnostics for checking the setup.

9 skills+233k
@mksglu@mksglu
Install Plugin
ai-memory

ai-memory

Gives coding agents a git-backed wiki for long-term project memory, with skills for retrieval, durable notes, session handoffs between agents, cross-project messaging, and memory consolidation.

7 skills+216k
@akitaonrails@akitaonrails
Install Plugin
claude-subconscious

claude-subconscious

A background agent for Claude Code that watches your sessions and reads your files, builds up memory over time, and offers guidance back during your work.

4 hooks2.9k
@letta-ai@letta-ai
Install Plugin
token-optimizer

token-optimizer

Audits a Claude Code or Codex setup for context-window waste, fixes it with the user's approval, and measures the savings. It also tracks costs, helps resume prior sessions, and offers a usage dashboard.

5 skills+22.8k
@alexgreensh@alexgreensh
Install Plugin
claude-reflect

claude-reflect

A self-learning system for Claude Code that captures your corrections during sessions and reminds you to run /reflect to save them to CLAUDE.md as permanent memory. It also discovers recurring workflow patterns.

1 skill+21.9k
@bayramannakov@bayramannakov
Install Plugin

Questions about the hooks in letta-code.

Which coding agents can use the hooks in letta-code?
The hooks in letta-code install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.