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:..., orexec:...)
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 cursorMACRO_NAME: current macro nameLECTIC_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 passesargsto the expansion as theARGenvironment variable.
When Lectic processes the file, it replaces the macro directive with the full text from its expansion field.
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:
iconfor fold display iconnamefor 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]setsARG=hello.- If you explicitly set an
ARGattribute, 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 variableFOOtobar.:name[]{EMPTY}sets the variableEMPTYto 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). Ifprereturns 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 topostas theARGvariable.
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.
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]]]:cache’spreruns. If the cache exists for the raw text of the children, it returns the cached summary. Lectic replaces the:cacheblock with this text and is done.- If
prereturns nothing (cache miss), Lectic enters the children. :fetchexpands to the file content.:summarizeprocesses that content.- Finally,
:cache’spostruns.ARGcontains the summary. It writesARGto the cache and outputs it.