Automation: Hooks

Hooks are a powerful automation feature that let you run custom commands and scripts in response to events in Lectic’s lifecycle. Use them for logging, notifications, post‑processing, or integrating with other tools and workflows.

Hooks are defined in your YAML configuration under the hooks key, per-tool in the hooks key of a tool specification, or per-interlocutor in the hooks key of an interlocutor specification.

Hook configuration

A hook has nine possible fields:

  • on: (Required) A single event name or a list of event names to listen for.
  • do: (Required) The command or inline script to run when the event fires.
  • inline: (Optional) A boolean. If true, the standard output of the command is captured and injected into the conversation. Defaults to false. Inline injection is only meaningful for user_message, assistant_message, and the assistant_* aliases.
  • inline_as: (Optional) Controls how inline hook output is recorded. One of attachment or comment. Defaults to attachment. attachment stores the output as hook XML and also makes it visible to the provider. comment stores the output as an HTML comment in the transcript and does not send it to the provider.
  • name: (Optional) A string name for the hook. If multiple hooks have the same name (e.g., one in your global config and one in a project config), the one defined later (or with higher precedence) overrides the earlier one. This allows you to replace default hooks with custom behavior. For inline hooks recorded as attachments, this name is serialized into the attachment XML and shown by LSP folding.
  • icon: (Optional) Icon string for inline hook attachments.
  • env: (Optional) A map of environment variables to inject into the hook’s execution environment.
  • allow_failure: (Optional) A boolean. If true, non-zero exit status from this hook is ignored. Defaults to false.
  • mode: (Optional) How the hook runs. One of sync, background, or detached. Defaults to sync.
    • sync: run immediately and wait for completion.
    • background: start immediately, do not block the current hook site, but wait for completion before Lectic exits. Background hook failures can still fail the run.
    • detached: start immediately and do not wait for completion. Detached hooks are best-effort after launch. Their eventual exit status does not affect the current run, and inline generated scripts are not cleaned up by Lectic.
hooks:
  - name: logger
    on: [assistant_message, user_message]
    env:
      LOG_FILE: /tmp/lectic.log
    do: ./log-activity.sh

If do contains multiple lines, it is treated as a script and must begin with a shebang (e.g., #!/bin/bash). If it is a single line, it is treated as a command.

Single-line hook commands are executed directly (not through a shell), so shell features like command substitution will not work. They use the same lightweight tokenization as single-line exec tools: Lectic first splits the command string into argv using simple shell-like quotes, then expands environment variables within each token.

This means quoted tokens stay together after expansion. For example, if a path contains spaces, quote it in YAML so it remains one argv element.

Hook commands run synchronously by default.

If you set mode: background, Lectic starts the hook immediately and lets main execution continue, but it still waits for the hook before exit. This is useful for progress updates and similar side effects that should finish reliably.

If you set mode: detached, Lectic only waits long enough to start the hook process. The hook then continues independently. This is useful when the hook should outlive Lectic entirely. For generated multi-line scripts, Lectic makes no parent-side cleanup attempt, so the script should remove itself if needed.

For most events, a non-zero exit status is treated as an error and aborts the current run.

tool_use_pre is special: a non-zero exit blocks the tool call (permission denied). If you set allow_failure: true on that hook, the non-zero exit is ignored and the tool call continues.

If you set inline: true, standard output is captured.

Only mode: sync hooks can set inline: true.

  • With the default inline_as: attachment, the output is added to the conversation.
    • For user_message events, the output is injected as context for the LLM before it generates a response. It also appears at the top of the assistant’s response block.
    • For assistant_message events, the output is appended to the end of the assistant’s response block. This will trigger another reply from the assistant, so be careful to only fire an inline hook when you want the assistant to generate more content.
  • With inline_as: comment, the output is recorded in the transcript as an HTML comment. It is not sent to the provider, and it does not keep the assistant loop alive. This is useful for unobtrusive logging.

In the .lec file, inline hook output is stored either as an XML <inline-attachment kind="hook"> block or, with inline_as: comment, as an HTML comment. See Inline Attachments for details on the storage format.

Available events and environment

When an event fires, the hook process receives context via environment variables. No positional arguments are passed. Some events also receive stdin.

Message events

  • user_message
    • Environment:
      • USER_MESSAGE: The text of the most recent user message.
      • LECTIC_INTERLOCUTOR: Active interlocutor name.
      • LECTIC_MODEL: Active model name.
      • MESSAGES_LENGTH: Message count including the current user message.
      • Standard Lectic variables (LECTIC_FILE, LECTIC_CONFIG, LECTIC_DATA, LECTIC_CACHE, LECTIC_STATE, LECTIC_TEMP).
    • When: Just before the provider request.
  • assistant_message
    • Standard Input: Raw markdown conversation body up to this point.
    • Environment:
      • ASSISTANT_MESSAGE: Full assistant text for this pass.
      • LECTIC_INTERLOCUTOR: Active interlocutor name.
      • LECTIC_MODEL: Active model name.
      • TOOL_USE_DONE: 1 when there are no pending tool calls.
      • TOKEN_USAGE_INPUT, TOKEN_USAGE_CACHED, TOKEN_USAGE_OUTPUT, TOKEN_USAGE_TOTAL: Usage for this assistant pass (if available).
      • LOOP_COUNT: Tool loop iteration index (0-based).
      • FINAL_PASS_COUNT: Number of final passes kept alive by inline hooks.
      • Standard Lectic variables as above.
    • When: Immediately after assistant streaming finishes for a pass.

User aliases

These are derived aliases of user_message:

  • user_first
    • Fires only on the first user message (MESSAGES_LENGTH=1).

If both base and alias hooks are configured, Lectic executes base user_message hooks first, then the alias hooks.

Assistant aliases

These are derived aliases of assistant_message:

  • assistant_final
    • Fires only when TOOL_USE_DONE=1.
  • assistant_intermediate
    • Fires only when TOOL_USE_DONE!=1.

Alias resolution happens internally in Lectic. You do not need shell-side conditionals for TOOL_USE_DONE or MESSAGES_LENGTH.

If both base and alias hooks are configured for a pass, Lectic executes base assistant_message hooks first, then the alias hooks.

Tool events

  • tool_use_pre
    • Environment:
      • TOOL_CALL_ID: Stable id for this specific tool call.
      • TOOL_NAME: Tool name.
      • TOOL_ARGS: JSON string of tool arguments.
      • Token usage variables (if available).
      • Standard Lectic variables.
    • When: After arguments are collected, before execution.
    • Behavior: Non-zero exit blocks the call (permission denied), unless allow_failure: true is set on that hook.
  • tool_use_post
    • Environment:
      • TOOL_CALL_ID: Stable id for this specific tool call.
      • TOOL_NAME: Tool name.
      • TOOL_ARGS: JSON string of tool arguments.
      • TOOL_CALL_RESULTS: JSON string on success.
      • TOOL_CALL_ERROR: JSON string on failure.
      • TOOL_DURATION_MS: Milliseconds for the attempted call.
      • Token usage variables (if available).
      • Standard Lectic variables.
    • When: After each tool attempt (success, failure, timeout, blocked).

Run events

Run events happen at the beginning and end of each lectic invocation.

  • run_start
    • Environment:
      • RUN_ID: Stable id for this invocation.
      • RUN_STARTED_AT: ISO timestamp.
      • RUN_CWD: Current working directory.
    • When: After config/load, before the first provider request.
  • run_end
    • Environment:
      • RUN_ID: Stable id for this invocation.
      • RUN_STATUS: success or error.
      • RUN_DURATION_MS: Invocation duration in ms.
      • RUN_ERROR_MESSAGE: Present on error.
      • TOKEN_USAGE_INPUT, TOKEN_USAGE_CACHED, TOKEN_USAGE_OUTPUT, TOKEN_USAGE_TOTAL: Totals for the invocation (if available).
    • When: Once per invocation after completion or uncaught error handling.

Error alias

  • error
    • Derived alias of run_end.
    • Fires only when RUN_STATUS=error.
    • Runs after run_end hooks for the same pass.
    • Environment:
      • Everything from run_end.
      • ERROR_MESSAGE (same value as RUN_ERROR_MESSAGE).

Hook headers and attributes

Hooks can pass metadata back to Lectic by including headers at the very beginning of their output. Headers follow the format LECTIC:KEY:VALUE or simply LECTIC:KEY (where the value defaults to “true”) and must appear before any other content. The headers are stripped from the visible output and stored as attributes on the inline attachment block.

#!/usr/bin/env bash
echo "LECTIC:final"
echo ""
echo "System check complete. One issue found."

This would be recorded roughly like this:

<inline-attachment kind="hook" final="true">
<command>./my-hook.sh</command>
<content type="text/plain">
┆System check complete. One issue found.
</content>
</inline-attachment>

If you instead set inline_as: comment, the visible transcript record is a plain HTML comment:

hooks:
  - on: assistant_message
    inline: true
    inline_as: comment
    do: echo "background log entry"
<!--
background log entry
-->

Two headers affect control flow. These are meaningful for attachment-mode inline hooks:

  • final: When an inline hook generates output, Lectic normally continues the tool calling loop so that the assistant can see and respond to the new information. If the final header is present, Lectic prevents this extra pass, allowing the conversation turn to end immediately (unless the assistant explicitly called a tool).
  • reset: When present, this header clears the conversation context up to the current message. The accumulated history sent to the provider is discarded, and the context effectively restarts from the message containing the hook output. This is useful for implementing custom context compaction or archival strategies when token limits are reached.

Example: A simple logging hook

Let’s start with the simplest possible hook: logging every message to a file. This helps you understand the basics before moving to more complex examples.

hooks:
  - on: [user_message, assistant_message]
    do: |
      #!/usr/bin/env bash
      echo "$(date): Message received" >> /tmp/lectic.log

This hook fires on both user and assistant messages. It appends a timestamp to a log file. That’s it—no return value, no interaction with the conversation.

Example: Human-in-the-loop tool confirmation

This example uses tool_use_pre to require confirmation before any tool execution. It uses zenity to show a dialog box with the tool name and arguments.

hooks:
  - on: tool_use_pre
    do: |
      #!/usr/bin/env bash
      # Display a confirmation dialog
      zenity --question \
             --title="Allow Tool Use?" \
             --text="Tool: $TOOL_NAME\nArgs: $TOOL_ARGS"
      # Zenity exits with 0 for Yes/OK and 1 for No/Cancel
      exit $?

If you are running inside an editor with the Lectic LSP, you can also route confirmation through the editor bridge instead of a desktop-specific dialog. Install the optional lectic editor subcommand plugin from extra/plugins/editor/ and use:

hooks:
  - on: tool_use_pre
    do: |
      #!/usr/bin/env bash
      lectic editor approve \
        --title "Allow tool use?" \
        --message "Tool: $TOOL_NAME\n\nArgs:\n$TOOL_ARGS"

This exits 0 when the user allows the action and non-zero otherwise, so it fits the tool_use_pre hook directly.

Example: Persisting messages to SQLite

This example persists every user and assistant message to an SQLite database located in your Lectic data directory. You can later query this for personal memory, project history, or analytics.

Configuration:

hooks:
  - on: [user_message, assistant_message]
    do: |
      #!/usr/bin/env bash
      set -euo pipefail
      DB_ROOT="${LECTIC_DATA:-$HOME/.local/share/lectic}"
      DB_PATH="${DB_ROOT}/memory.sqlite3"
      mkdir -p "${DB_ROOT}"

      # Determine role and text from available variables
      if [[ -n "${ASSISTANT_MESSAGE:-}" ]]; then
        ROLE="assistant"
        TEXT="$ASSISTANT_MESSAGE"
      else
        ROLE="user"
        TEXT="${USER_MESSAGE:-}"
      fi

      # Basic sanitizer for single quotes for SQL literal
      esc_sq() { printf %s "$1" | sed "s/'/''/g"; }

      TS=$(date -Is)
      FILE_PATH="${LECTIC_FILE:-}"
      NAME="${LECTIC_INTERLOCUTOR:-}"

      sqlite3 "$DB_PATH" <<SQL
      CREATE TABLE IF NOT EXISTS memory (
        id INTEGER PRIMARY KEY,
        ts TEXT NOT NULL,
        role TEXT NOT NULL,
        interlocutor TEXT,
        file TEXT,
        text TEXT NOT NULL
      );
      INSERT INTO memory(ts, role, interlocutor, file, text)
      VALUES ('${TS}', '${ROLE}', '$(esc_sq "$NAME")',
              '$(esc_sq "$FILE_PATH")', '$(esc_sq "$TEXT")');
      SQL

Notes:

  • Requires the sqlite3 command-line tool to be installed and on your PATH.
  • The hook inspects which variable is set to decide whether the event was a user or assistant message.
  • LECTIC_FILE is populated when using -f and may be empty when streaming from stdin.
  • Adjust the table schema to suit your use case.

Example: Automatically injecting context

This example automatically runs date before every user message and injects the output into the context. This allows the LLM to always know the date and time without you needing to run :cmd[date].

hooks:
  - on: user_message
    inline: true
    do: |
      #!/usr/bin/env bash
      echo "<date-and-time>"
      date
      echo "</date-and-time>"

Example: Notification when work completes

This example sends a desktop notification when the assistant finishes a tool-use workflow.

hooks:
  - on: assistant_final
    do: |
      #!/usr/bin/env bash
      notify-send "Lectic" "Assistant finished working"

This is especially useful for long-running agentic tasks where you want to step away and be alerted when the assistant is done.

If you want progress inside the editor instead, pair a hook or helper script with the optional lectic editor plugin and send progress updates through the LSP bridge:

TOKEN="${RUN_ID:-lectic-run}"
lectic editor progress begin \
  --token "$TOKEN" \
  --title "Running assistant task"

Example: Neovim notification from hooks

When using the lectic.nvim plugin, the NVIM environment variable is set to Neovim’s RPC server address. This allows hooks to communicate directly with your editor—sending notifications, opening windows, or triggering any Neovim Lua API.

This example sends a notification to Neovim when the assistant finishes working:

hooks:
  - on: assistant_final
    do: |
      #!/usr/bin/env bash
      if [[ -n "${NVIM:-}" ]]; then
        nvim --server "$NVIM" --remote-expr \
          "luaeval('vim.notify(\"Lectic: Assistant finished working\", vim.log.levels.INFO)')"
      fi

The pattern nvim --server "$NVIM" --remote-expr "luaeval('...')" lets you execute arbitrary Lua in the running Neovim instance. Some ideas:

  • Update a status line variable
  • Trigger a custom autocommand: vim.api.nvim_exec_autocmds('User', {pattern = 'LecticDone'})

Example: Reset context on token limit

This example checks the total token usage and, if it exceeds a limit, resets the conversation context. It also uses the final header to stop the assistant from responding to the reset message immediately.

hooks:
  - on: assistant_message
    inline: true
    do: |
      #!/usr/bin/env bash
      LIMIT=100000
      TOTAL="${TOKEN_USAGE_TOTAL:-0}"
      
      if [ "$TOTAL" -gt "$LIMIT" ]; then
        echo "LECTIC:reset"
        echo "LECTIC:final"
        echo ""
        echo "**Context cleared (usage: $TOTAL tokens).**"
      fi