Writing hooks for LLxprt Code

August 8, 2026 · View on GitHub

This tutorial walks you through creating hooks — external scripts that LLxprt Code runs at key points during execution to enforce policies, modify tool inputs, log activity, and add context. You will go from a minimal logging hook to advanced patterns that combine multiple hook events.

Goal and audience

You are a developer, administrator, or power user who wants to extend LLxprt Code beyond its built-in behavior. After finishing this tutorial you will be able to write, configure, test, and troubleshoot hook scripts that block operations, modify tool calls, restrict the available tools, and inject context into the conversation.

For the complete type definitions, event inputs, output fields, and exit-code semantics, see the API Reference. For security, performance, and privacy guidance, see Best Practices.

Prerequisites

  • Node.js 24+ — required to run LLxprt Code itself.
  • LLxprt Code installed and configured.
  • Basic familiarity with shell scripting (bash) or Python.
  • Comfort reading and writing JSON.

Hooks are disabled by default. Before any hook you configure will run, you must enable the hook system. Add the following to your user settings.json:

{
  "hooksConfig": {
    "enabled": true
  }
}

There is also an experimental gate (tools.enableHooks) that defaults to true. Both hooksConfig.enabled and tools.enableHooks must be true for hooks to execute.

Note: The hooks.enabled key is a legacy form. LLxprt Code migrates it to hooksConfig.enabled automatically, but prefer the canonical key going forward.

Quick start

Create a minimal hook that logs every tool execution. This is the fastest way to confirm hooks are wired up correctly.

1. Write the hook script

Hook scripts can live anywhere on your system — the command field accepts any path. The examples below keep scripts under your project's workspace-local .llxprt/hooks/ directory so they are easy to share via version control. You may equally keep scripts under LLxprt's config directory or any other directory you prefer.

mkdir -p .llxprt/hooks
cat > .llxprt/hooks/log-tools.sh << 'EOF'
#!/usr/bin/env bash
# Read JSON input from stdin
input=$(cat)

# Extract the tool name
tool_name=$(echo "$input" | jq -r '.tool_name')

# Log to a file
echo "[$(date)] Tool executed: $tool_name" >> .llxprt/tool-log.txt

# Return success (exit 0) — stdout is shown to the user in transcript mode
echo "Logged: $tool_name"
EOF

chmod +x .llxprt/hooks/log-tools.sh

2. Configure the hook

Add the hook to .llxprt/settings.json (project-level) or your user-level settings.json:

{
  "hooks": {
    "AfterTool": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "tool-logger",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/log-tools.sh",
            "description": "Log all tool executions"
          }
        ]
      }
    ]
  }
}

3. Test the hook

Run LLxprt Code and ask it to perform any tool-based action:

> Read the README.md file

[Agent uses the read_file tool]

Logged: read_file

Check .llxprt/tool-log.txt to see the logged tool executions. If nothing appears, see Troubleshooting below.

How hooks work

Every hook is a command (typically a shell script) that receives JSON input on stdin and returns JSON output on stdout. Based on the exit code and the JSON output, LLxprt Code can block an operation, modify a tool input, inject context, or simply continue.

Exit codes and output streams

Exit codeMeaning
0Success — execution continues.
1Non-blocking error (warning) — execution continues.
2Blocking error — the operation is blocked or denied.

The exit code and the output stream you choose together determine how LLxprt Code interprets your hook. There are two reliable ways to block an operation — pick one and use it consistently:

  1. Structured decision (stdout + exit 0). Write a JSON object such as {"decision":"deny","reason":"..."} to stdout and exit 0. When the exit code is 0, LLxprt Code parses stdout as JSON. Use this when you want full control over every field of the decision.
  2. Plain-text denial (stderr + exit 2). Write a human-readable message to stderr and exit 2. When the exit code is 2, LLxprt Code converts the stderr text into {"decision":"deny","reason":"<stderr text>"}. Use this for quick scripts that do not need structured output.

Warning: Do not write deny JSON to stdout and exit 2. When the exit code is non-zero, LLxprt Code ignores stdout entirely and reads only stderr. If stderr is empty, the hook produces no decision at all and the tool call is allowed — your hook silently fails open.

When stdout is plain text (not JSON) and the exit code is 0, LLxprt Code converts it to { "decision": "allow", "systemMessage": ... }. This is useful for returning informational messages.

Variables in the command string

Hook command strings are handed to a real platform shell, so two layers of expansion apply:

  1. LLxprt Code substitutes $LLXPRT_PROJECT_DIR. Before invoking the shell, LLxprt Code replaces every $LLXPRT_PROJECT_DIR token with the current working directory, shell-escaping the value first. This lets you reference project-relative paths regardless of where LLxprt Code is launched from:

    {
      "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/my-hook.sh"
    }
    
  2. The shell then performs its normal expansion. Because the string runs through your platform shell, ordinary shell features also work — ${VAR:-default}, $HOME, ~, command substitution, and quoting. For example, ${LLXPRT_CONFIG_HOME:-$HOME/.config/llxprt-code} resolves correctly inside a command because the shell evaluates it at run time.

LLxprt Code only substitutes $LLXPRT_PROJECT_DIR itself; every other expansion is done by the shell, not by LLxprt Code.

Matcher patterns

Each HookDefinition accepts an optional matcher field that controls which invocations the hook applies to:

  • For tool events (BeforeTool, AfterTool), the matcher is tested against the tool name. It is treated as a regular expression; if the pattern is not valid regex, an exact string match is used instead.
  • For session events (SessionStart, SessionEnd), the matcher is tested against the source or reason (for example, startup, resume, clear, exit).
  • Omitting the matcher, or setting it to "" or "*", matches everything.
{
  "matcher": "write_file|replace",
  "hooks": [ ... ]
}

For the full list of events and their input/output schemas, see the API Reference.

Step-by-step examples

The following examples cover the most common hook use cases. Each one is self-contained: write the script, add the configuration block, and test.

Block writes to sensitive directories

A BeforeTool hook that prevents writes to system directories.

.llxprt/hooks/block-sensitive-writes.sh:

#!/usr/bin/env bash
#
# BeforeTool hook: block writes to sensitive directories.
#
input=$(cat)

tool_name=$(echo "$input" | jq -r '.tool_name // empty')

# Only check write operations
if [[ "$tool_name" != "write_file" && "$tool_name" != "replace" ]]; then
  echo '{"decision": "allow"}'
  exit 0
fi

# write_file and replace both use "absolute_path" as the primary path field
target_path=$(echo "$input" | jq -r '.tool_input.absolute_path // .tool_input.file_path // empty')

# Block writes to system directories
sensitive_dirs=("/etc" "/var" "/usr" "/System" "/Library")
for dir in "${sensitive_dirs[@]}"; do
  if [[ "$target_path" == "$dir"* ]]; then
    echo "{\"decision\": \"deny\", \"reason\": \"Writing to $dir is prohibited by security policy\"}"
    exit 0
  fi
done

echo '{"decision": "allow"}'
exit 0

.llxprt/settings.json:

{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "block-sensitive-writes",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/block-sensitive-writes.sh",
            "timeout": 5000
          }
        ]
      }
    ]
  }
}

Prevent committing secrets

A BeforeTool hook that scans file content for API keys and passwords before writes.

.llxprt/hooks/block-secrets.sh:

#!/usr/bin/env bash
input=$(cat)

content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')

if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then
  echo 'Potential secret detected' >&2
  exit 2
fi

exit 0

.llxprt/settings.json:

{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "secret-scanner",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/block-secrets.sh",
            "description": "Prevent writing files that contain secrets"
          }
        ]
      }
    ]
  }
}

Note: For more robust secret-detection patterns (AWS keys, GitHub tokens, OpenAI keys), see the secret-scanning guidance in Best Practices.

Audit-log all tool executions

An AfterTool hook that records every tool call to a log file.

.llxprt/hooks/audit-log.sh:

#!/usr/bin/env bash
#
# AfterTool hook: log all tool executions.
#
input=$(cat)

# Extract the scalar fields we want to record. jq -r yields raw (unquoted)
# strings, which we then pass back to jq as --arg values so they are encoded
# correctly no matter what characters they contain.
timestamp=$(echo "$input" | jq -r '.timestamp')
session_id=$(echo "$input" | jq -r '.session_id')
tool_name=$(echo "$input" | jq -r '.tool_name')

# Write to LLxprt's canonical log/state directory, or choose any path your
# policy requires.
log_dir="${LLXPRT_LOG_HOME:-${LLXPRT_CONFIG_HOME:-$HOME/.local/state/llxprt-code}}"
mkdir -p "$log_dir"
log_file="$log_dir/audit.log"

# Build the JSON record with jq -n, passing the scalar fields as --arg strings
# and embedding tool_input / tool_response as proper JSON values (--argjson)
# taken directly from the input. This guarantees valid JSON even when the data
# contains quotes, newlines, or control characters.
jq -n \
  --arg timestamp "$timestamp" \
  --arg session "$session_id" \
  --arg tool "$tool_name" \
  --argjson payload "$input" \
  '{timestamp: $timestamp, session: $session, tool: $tool,
    input: $payload.tool_input, response: $payload.tool_response}' >> "$log_file"

exit 0

Warning: audit logs are sensitive. Tool inputs and responses can contain file contents, environment values, API keys, or other secrets. Before enabling an audit hook, set restrictive file permissions on the log file (for example, chmod 600), and decide on a redaction and retention policy that matches your organization's requirements. A shared or world-readable audit log can become a concentrated store of secrets.

.llxprt/settings.json:

{
  "hooks": {
    "AfterTool": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "audit-log",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/audit-log.sh"
          }
        ]
      }
    ]
  }
}

Modify tool input (add safety flags)

A BeforeTool hook that automatically inserts the -i flag into rm commands before they execute.

.llxprt/hooks/safe-shell.sh:

#!/usr/bin/env bash
#
# BeforeTool hook: add safety flags to dangerous shell commands.
#
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')

if [[ "$tool_name" != "run_shell_command" ]]; then
  exit 0
fi

command=$(echo "$input" | jq -r '.tool_input.command')

# Add -i (interactive) to rm if not already present
if [[ "$command" == rm\ * ]]; then
  if [[ "$command" != *"-i"* && "$command" != *"--interactive"* ]]; then
    safe_command=$(echo "$command" | sed 's/^rm /rm -i /')
    # Build the structured response with jq -n so $safe_command is encoded as a
    # proper JSON string. Interpolating it into a raw echo would break on
    # quotes, backslashes, or newlines in the command.
    jq -n \
      --arg command "$safe_command" \
      '{decision: "allow", hookSpecificOutput: {tool_input: {command: $command}}}'
    exit 0
  fi
fi

exit 0

.llxprt/settings.json:

{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "run_shell_command",
        "hooks": [
          {
            "name": "safe-shell",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/safe-shell.sh"
          }
        ]
      }
    ]
  }
}

Restrict available tools by directory

A BeforeToolSelection hook that limits the model to read-only tools in production directories.

.llxprt/hooks/restrict-tools.sh:

#!/usr/bin/env bash
#
# BeforeToolSelection hook: restrict tools based on working directory.
#
input=$(cat)
cwd=$(echo "$input" | jq -r '.cwd')

# In production directories, only allow read operations
if [[ "$cwd" == */production/* || "$cwd" == */prod/* ]]; then
  echo '{
    "hookSpecificOutput": {
      "toolConfig": {
        "mode": "AUTO",
        "allowedFunctionNames": ["read_file", "read_many_files", "glob", "search_file_content", "list_directory"]
      }
    }
  }'
  exit 0
fi

exit 0

.llxprt/settings.json:

{
  "hooks": {
    "BeforeToolSelection": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "restrict-tools",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/restrict-tools.sh"
          }
        ]
      }
    ]
  }
}

Rate-limit model calls

A Python BeforeModel hook that enforces a maximum number of LLM calls per minute.

.llxprt/hooks/rate-limit.py:

#!/usr/bin/env python3
"""
BeforeModel hook: rate-limit LLM calls.
"""
import json
import sys
import time
from pathlib import Path

MAX_CALLS_PER_MINUTE = 10
STATE_FILE = Path.home() / '.llxprt-rate-limit-state.json'


def load_state():
    if STATE_FILE.exists():
        try:
            with open(STATE_FILE) as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError):
            pass
    return {'calls': []}


def save_state(state):
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    # Write to a temporary file then atomically rename, so a crash mid-write
    # never leaves a truncated JSON file behind.
    tmp = STATE_FILE.with_suffix('.tmp')
    with open(tmp, 'w') as f:
        json.dump(state, f)
    tmp.replace(STATE_FILE)


def main():
    json.load(sys.stdin)  # read and discard input

    state = load_state()
    now = time.time()

    # Drop calls older than 60 seconds
    state['calls'] = [t for t in state['calls'] if now - t < 60]

    if len(state['calls']) >= MAX_CALLS_PER_MINUTE:
        wait_time = int(60 - (now - state['calls'][0]))
        print(json.dumps({
            'continue': False,
            'stopReason': f'Rate limit exceeded. Please wait {wait_time} seconds.'
        }))
        sys.exit(0)

    state['calls'].append(now)
    save_state(state)

    print(json.dumps({'continue': True}))
    sys.exit(0)


if __name__ == '__main__':
    main()

Make it executable:

chmod +x .llxprt/hooks/rate-limit.py

Note: this example is not safe for concurrent sessions. The read-modify- write cycle in main() has a race condition: two BeforeModel invocations running at the same time can both read the same state, both pass the limit, and then overwrite each other's updates, letting through more calls than MAX_CALLS_PER_MINUTE allows. The atomic replace in save_state protects against a crash truncating the JSON, but it does not serialize access. If you need correct behavior across parallel sessions, add file locking (for example, Python's fcntl.flock) or move the counter to a small database with atomic increments.

.llxprt/settings.json:

{
  "hooks": {
    "BeforeModel": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "rate-limit",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/rate-limit.py",
            "timeout": 2000
          }
        ]
      }
    ]
  }
}

Inject dynamic context

A BeforeAgent hook that adds recent git history to the conversation before the model processes the prompt.

.llxprt/hooks/inject-context.sh:

#!/usr/bin/env bash

context=$(git log -5 --oneline 2>/dev/null || echo "No git history")

# Build the structured response with jq -n --arg so the commit text is encoded
# as a proper JSON string. A heredoc would break on quotes, newlines, or
# control characters that git commit messages can contain.
jq -n \
  --arg context "Recent commits:\n$context" \
  '{hookSpecificOutput: {hookEventName: "BeforeAgent", additionalContext: $context}}'

.llxprt/settings.json:

{
  "hooks": {
    "BeforeAgent": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "git-context",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/inject-context.sh",
            "description": "Inject recent git commit history"
          }
        ]
      }
    ]
  }
}

Run tests after code changes

An AfterTool hook that automatically runs the test file corresponding to a source file you just edited.

.llxprt/hooks/auto-test.sh:

#!/usr/bin/env bash
input=$(cat)

file_path=$(echo "$input" | jq -r '.tool_input.absolute_path // .tool_input.file_path // empty')

# Only test .ts files
if [[ ! "$file_path" =~ \.ts$ ]]; then
  exit 0
fi

test_file="${file_path%.ts}.test.ts"

if [ ! -f "$test_file" ]; then
  echo "No test file found for $file_path"
  exit 0
fi

# Capture the test output and its real exit status separately. Piping
# directly into `head` would lose the test command's exit code (the pipeline
# would report head's status instead), so a failing test run could be
# reported as a success.
test_output=$(bun test "$test_file" 2>&1 | head -20)
test_status=${PIPESTATUS[0]}

if [ "$test_status" -eq 0 ]; then
  echo "Tests passed"
  exit 0
else
  # Report the failure on stderr with exit 2 so LLxprt Code surfaces it as a
  # blocking decision rather than silently treating it as a success.
  echo "Tests failed for $test_file" >&2
  exit 2
fi

.llxprt/settings.json:

{
  "hooks": {
    "AfterTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "auto-test",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/auto-test.sh",
            "description": "Run tests after code changes"
          }
        ]
      }
    ]
  }
}

Advanced patterns

Multiple hooks with matchers

You can configure different hooks for different tools within the same event. Hooks without a matcher run for every invocation.

{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "validate-writes",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/validate-writes.sh"
          }
        ]
      },
      {
        "matcher": "run_shell_command",
        "hooks": [
          {
            "name": "validate-shell",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/validate-shell.sh"
          }
        ]
      },
      {
        "hooks": [
          {
            "name": "audit-all",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/audit-all.sh"
          }
        ]
      }
    ]
  }
}

In this configuration:

  • validate-writes.sh runs only for write_file and replace.
  • validate-shell.sh runs only for run_shell_command.
  • audit-all.sh runs for every tool (no matcher).

Sequential hook chaining

Multiple hooks for the same event run in parallel by default. Set "sequential": true on a HookDefinition to run its hooks one after another, where each hook can build on the previous hook's output:

{
  "hooks": {
    "BeforeAgent": [
      {
        "matcher": "*",
        "sequential": true,
        "hooks": [
          {
            "name": "load-memories",
            "type": "command",
            "command": "./hooks/load-memories.sh"
          },
          {
            "name": "analyze-sentiment",
            "type": "command",
            "command": "./hooks/analyze-sentiment.sh"
          }
        ]
      }
    ]
  }
}

When sequential is true, a hook's hookSpecificOutput modifications (for example, tool_input on BeforeTool, additionalContext on BeforeAgent, or llm_request on BeforeModel) are applied to the input passed to the next hook in the chain.

Note: If any HookDefinition in an event has sequential: true, all hooks for that event run sequentially.

Combining events: a complete workflow

The following configuration wires hooks across the full session lifecycle. This is a reference for how events fit together — each script would contain your own logic. See the individual examples above for the script bodies.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "name": "init",
            "type": "command",
            "command": "node $LLXPRT_PROJECT_DIR/.llxprt/hooks/init.js",
            "description": "Initialize session state"
          }
        ]
      }
    ],
    "BeforeAgent": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "inject-context",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/inject-context.sh"
          }
        ]
      }
    ],
    "BeforeToolSelection": [
      {
        "matcher": "*",
        "hooks": [
          {
            "name": "restrict-tools",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/restrict-tools.sh"
          }
        ]
      }
    ],
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "block-secrets",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/block-secrets.sh"
          }
        ]
      }
    ],
    "AfterTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {
            "name": "auto-test",
            "type": "command",
            "command": "$LLXPRT_PROJECT_DIR/.llxprt/hooks/auto-test.sh"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "matcher": "exit",
        "hooks": [
          {
            "name": "cleanup",
            "type": "command",
            "command": "node $LLXPRT_PROJECT_DIR/.llxprt/hooks/cleanup.js",
            "description": "Persist session learnings"
          }
        ]
      }
    ]
  }
}

Common patterns

Allow by default, deny specific

Most policy hooks start by allowing everything and only intervene for specific tools or conditions:

case "$tool_name" in
  run_shell_command)
    if is_dangerous_command "$command"; then
      echo '{"decision": "deny", "reason": "Dangerous command blocked"}'
      exit 0
    fi
    ;;
esac

exit 0

Deny by default, allow specific

For high-security environments, deny everything except explicitly allowed operations:

case "$tool_name" in
  read_file|list_directory|glob)
    exit 0
    ;;
esac

echo '{"decision": "deny", "reason": "Tool not in allowlist"}'
exit 0

Security and limitations

Warning: Hooks execute with your full user privileges. A malicious or misconfigured hook can delete files, exfiltrate data, or compromise your system.

Key security points to keep in mind:

  • Folder trust is the gate. Project hooks (in .llxprt/settings.json) run only when the project folder is trusted. When LLxprt Code first encounters a new or changed project hook, it prints a warning listing the hook so you can review it, but it does not block the hook or prompt for per-hook approval. Once a folder is trusted, every project hook in it executes with your privileges — including any hook added or modified by a collaborator after a git pull. Treat a project hook the same way you would treat any other script committed to the repository: review its command before you trust the folder.
  • Hooks inherit the environment of the LLxprt Code process, which may include API keys. LLxprt Code attempts to sanitize sensitive variables, but you should avoid printing environment variables to stdout or stderr.
  • Telemetry: when telemetry is enabled, hook inputs and outputs are included in the hook_call telemetry event regardless of the telemetry.logPrompts setting. Disable telemetry entirely to prevent hook I/O from being logged.

For the complete threat model, mitigation strategies (sandboxing, permission limiting), and privacy guidance, see Best Practices.

Troubleshooting

Hook not running

  1. Verify hooks are enabled. Confirm hooksConfig.enabled is true in your settings.json and that tools.enableHooks is not set to false.
  2. Check the hook panel. Run /hooks panel inside LLxprt Code and confirm the hook appears and is enabled.
  3. Check the disabled list. Hooks listed under hooksConfig.disabled will not execute even if configured.
  4. Verify the script is executable: chmod +x .llxprt/hooks/my-hook.sh
  5. Validate your settings JSON: jq . .llxprt/settings.json — a syntax error will prevent all hooks from loading.
  6. Verify the matcher pattern matches the tool name. Remember matchers are tested as regular expressions for tool events.

Hook output not parsed

  1. Ensure stdout is valid JSON when you want structured output: echo '{"test": 1}' | jq .
  2. Do not mix logging with JSON output. Write log messages to a file or to stderr, not stdout. Stdout is reserved for hook output.
  3. Check for binary data or control characters in the output that could break JSON parsing.

Hook times out

The default timeout is 60 seconds (60000 ms). You can set a shorter timeout (in milliseconds) per hook.

  1. Profile the hook to find the slow part.
  2. Set an appropriate timeout in the hook configuration:
    {
      "name": "my-hook",
      "timeout": 5000
    }
    
  3. Avoid slow operations like network calls inside hooks, or wrap them with a local timeout (timeout 1 ./quick-check.sh).

Environment variables not available

LLxprt Code only substitutes $LLXPRT_PROJECT_DIR itself before invoking the shell; every other expansion ($HOME, ${VAR:-default}, and so on) is performed by your platform shell at run time. If your hook script needs additional variables that the shell does not already see, load them from a .env file inside the script:

if [ -f "$LLXPRT_PROJECT_DIR/.env" ]; then
  source "$LLXPRT_PROJECT_DIR/.env"
fi