Skip to content
Development
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
3k20 skills8 hooks
Install
$ npx -y skills add letta-ai/letta-code --agent claude-code

Ships with letta-code. Installing the plugin gets these hooks.

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.

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.

Get the whole plugin