Automation: Macros

Lectic supports a simple but powerful macro system that allows you to define and reuse snippets of text. This is useful for saving frequently used prompts, automating repetitive workflows, and composing complex, multi-step commands.

Macros are defined in your YAML configuration (either in a .lec file’s header or in an included configuration file).

Defining Macros

Macros are defined under the macros key. Each macro must have a name and an expansion. You can optionally provide an env map to set default environment variables for the expansion. You can also provide an optional description, which is shown in LSP hover info for the macro.

You can also optionally provide completions for macro argument autocomplete inside :name[...]:

  • Inline list of { completion, detail?, documentation? }
  • External source string (file:..., file:local:..., or exec:...)

For source strings, the content/output must be a single YAML document that is a sequence of completion items. JSON arrays also work.

For exec: sources, the default trigger policy is manual (only on explicit completion invocation). You can override with completion_trigger: auto | manual.

exec: completion sources inherit the macro’s env, and also receive:

  • ARG / ARG_PREFIX: current bracket text up to cursor
  • MACRO_NAME: current macro name
  • LECTIC_COMPLETION=1
macros:
  - name: summarize
    expansion: >
      Please provide a concise, single-paragraph summary of our
      conversation so far, focusing on the key decisions made and
      conclusions reached.

  - name: build
    env:
      BUILD_DIR: ./dist
    expansion: exec:echo "Building in $BUILD_DIR"

Expansion Sources

The expansion field can be a simple string, or it can load its content from a file or from the output of a command, just like the prompt field. For full semantics of file: and exec:, see External Prompts.

  • File Source: expansion: file:./prompts/summarize.txt
  • Command/Script Source:
    • Single line: expansion: exec:get-prompt-from-db --name summarize (executed directly, not via a shell)

    • Multi‑line script: start with a shebang, e.g.

      expansion: |
        exec:#!/usr/bin/env bash
        echo "Hello, ${TARGET}!"

      Multi‑line scripts are written to a temp file and executed with the interpreter given by the shebang.

Using Macros

To use a macro, you invoke it by writing the macro name as the directive name:

  • :name[] expands the macro.
  • :name[args] expands the macro and also passes args to the expansion as the ARG environment variable.

When Lectic processes the file, it replaces the macro directive with the full text from its expansion field.

NoteBuilt-in Macros

Lectic includes several built-in macros described in the next section. Because they are macros, they compose naturally with user-defined macros. For example, you can wrap :cmd in a caching macro: :cache[:cmd[expensive-command]].

This was a long and productive discussion. Could you wrap it up?

:summarize[]

Built-in Macros Reference

Lectic provides several built-in macros for common operations. These are always available without any configuration.

:cmd — Execute a Command

Runs a shell command and expands to the output wrapped in XML.

What's my current directory? :cmd[pwd]

Expands to:

<stdout from="pwd">/home/user/project</stdout>

If the command fails, you get an error wrapper with both stdout and stderr. See External Content for full details on execution environment and error handling.

:env — Read Environment Variables

Expands to the value of an environment variable. Useful for injecting configuration or paths without running a command.

My home directory is :env[HOME]

If the variable is not set, :env expands to an empty string.

:fetch — Inline External Content as Text

Fetch content from a local path or URI and inline it into your message as a <file ...> block.

This is similar to using a Markdown link for attachments, but it produces inline text (which composes naturally with other macros).

Examples:

:fetch[./README.md]
:fetch[<https://example.com>]
:fetch[[notes](./notes.md)]

For non-text content (images, PDFs, etc.), prefer Markdown links so Lectic can attach the bytes to the provider request.

:verbatim — Prevent Expansion

Returns the raw child text without expanding any macros inside it.

Here's an example of macro syntax: :verbatim[:cmd[echo hello]]

Expands to:

Here's an example of macro syntax: :cmd[echo hello]

The inner :cmd is not executed — it appears literally in the output.

:once — Expand Only in Final Message

Only expands its children when processing the final (most recent) user message. In earlier messages, it expands to nothing.

This is useful for commands that should only run once, not be re-executed every time context is rebuilt:

:once[:cmd[expensive-analysis-script]]

When you add a new message and re-run Lectic, the :once directive in older messages will produce no output, while the one in your latest message will execute.

:discard — Evaluate and Discard

Expands and evaluates its children (including any commands), but discards the output entirely. Useful for side effects.

:discard[:cmd[echo "logged" >> activity.log]]

The command runs and writes to the log file, but nothing appears in the conversation. You can combine :once and :discard for cases where you only want the macro to run once, and you don’t want to pass the output to the LLM.

:attach — Create Inline Attachment

Captures its expanded children as an inline attachment stored in the assistant’s response block. Only processed in the final message.

You can optionally add attributes to control metadata:

  • icon for fold display icon
  • name for fold display label
:attach[:cmd[git diff --staged]]{name="staged diff" icon=""}

See External Content for full details on how inline attachments work and when to use them.

The Macro Expansion Environment

When a macro expands via exec, the script being executed can be passed information via environment variables.

Passing arguments to expansions via ARG

The text inside the directive brackets is passed to the macro expansion as the ARG environment variable.

This works for both single-line exec: commands and multi-line exec: scripts.

  • :name[hello] sets ARG=hello.
  • If you explicitly set an ARG attribute, it overrides the bracket content: :name[hello]{ARG="override"}.

Passing other environment variables via attributes

You can pass environment variables to a macro’s expansion by adding attributes to the macro directive. These attributes are injected into the environment of exec: expansions when they run.

  • :name[]{FOO="bar"} sets the variable FOO to bar.
  • :name[]{EMPTY} sets the variable EMPTY to the empty string. :name[]{EMPTY=""} is equivalent.

Notes: - Single‑line exec: commands are not run through a shell. If you need shell features, invoke a shell explicitly, e.g., exec: bash -c 'echo "Hello, $TARGET"'. - In single‑line commands, variables in the command string are expanded before execution. For multi‑line scripts, variables are available to the script via the environment.

Example

Configuration:

macros:
  - name: greet
    expansion: exec: bash -c 'echo "Hello, $TARGET!"'

Conversation:

:greet[]{TARGET="World"}

When Lectic processes this, the directive will be replaced by the output of the exec command, which is “Hello, World!”.

Other Environment Variables

A few other environment variables are available by default.

Name Description
MESSAGE_INDEX Index (starting from one) of the message containing the macro
MESSAGES_LENGTH Total number of messages in the conversation
MESSAGE_TEXT Raw text of the message containing the macro

These might be useful for conditionally running only if the macro is, e.g. part of the most recent user message, or for sniffing the context in which the macro is expanding for more intelligent LLM summarization.

Advanced Macros: Phases and Recursion

Macros can interact with each other recursively. To support complex workflows, macros can define two separate expansion phases: pre and post.

  • pre: Expanded when the macro is first encountered (pre-order traversal). If pre returns content, the macro is replaced by that content, which is then recursively expanded. The original children are discarded.
  • post: Expanded after the macro’s children have been processed (post-order traversal). The processed children are passed to post as the ARG variable.

If you define a macro with just expansion, it is treated as a post phase macro.

Here’s how the phases work for a nested macro call like :outer[:inner[content]]:

:outer[:inner[content]]
   │
   ▼
┌─────────────────────────────────────────────────────┐
│ 1. Run :outer's PRE                                 │
│    - If it returns content → replace :outer,        │
│      recursively expand the result, DONE            │
│    - If it returns nothing → continue to children   │
└─────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────┐
│ 2. Process children: :inner[content]                │
│    - Run :inner's PRE                               │
│    - Process :inner's children ("content")          │
│    - Run :inner's POST with children as ARG         │
│    - Replace :inner with result                     │
└─────────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────────┐
│ 3. Run :outer's POST                                │
│    - ARG = processed children (result of :inner)    │
│    - Replace :outer with result                     │
└─────────────────────────────────────────────────────┘

The key insight: pre lets you short-circuit (skip children entirely), or change the children of a directive, while post lets you wrap or transform the fully expanded results of the children.

Handling “No Operation” in Pre

If the pre script runs but produces no output (an empty string), Lectic treats this as a “pass-through”. The macro is NOT replaced; instead, Lectic proceeds to process the macro’s children and then runs the post phase.

This makes it easy to implement cache checks or conditional logic.

Tip

If you explicitly want to delete a node during the pre phase (stopping recursion and producing no output), you cannot return an empty string. Instead, return an empty HTML comment: <!-- -->. This stops recursion and renders as nothing.

Example: Caching

This design allows for powerful compositions, such as a caching macro that wraps expensive operations.

macros:
  - name: cache
    # Check for cache hit. If found, cat the file.
    # If not found, the script produces no output (empty string),
    # so Lectic proceeds to expand the children.
    pre: |
      exec:#!/bin/bash
      HASH=$(echo "$ARG" | md5sum | cut -d' ' -f1)
      if [ -f "/tmp/cache/$HASH" ]; then
        cat "/tmp/cache/$HASH"
      fi
    # If we reached post, it means pre didn't return anything (cache miss).
    # We now have the result of the children in ARG. Save it and output it.
    post: |
      exec:#!/bin/bash
      HASH=$(echo "$ARG" | md5sum | cut -d' ' -f1)
      mkdir -p /tmp/cache
      echo "$ARG" > "/tmp/cache/$HASH"
      echo "$ARG"

Usage:

:cache[:summarize[:fetch[file.txt]]]
  1. :cache’s pre runs. If the cache exists for the raw text of the children, it returns the cached summary. Lectic replaces the :cache block with this text and is done.
  2. If pre returns nothing (cache miss), Lectic enters the children.
  3. :fetch expands to the file content.
  4. :summarize processes that content.
  5. Finally, :cache’s post runs. ARG contains the summary. It writes ARG to the cache and outputs it.