Template System and Automation
July 8, 2026 · View on GitHub
Flemma's prompt pipeline runs through three stages: parse, evaluate, and send. Errors at any stage surface via diagnostics before the request leaves your editor. Template expressions and include() are part of the harness's environment-shaping surface — they let the harness inject context into what the model sees before the request leaves the editor.
For an overview of the
.chatbuffer format (role markers, frontmatter placement, thinking blocks), see Why Conversations as Files? in the README.
Frontmatter
Place a fenced block on the very first line of the buffer (```lua or ```json). The block returns a table of variables that become available in {{ expressions }} throughout the file.
```lua
recipient = "QA team"
notes = [[
- Verify presets list before providers.
- Check spinner no longer triggers spell checking.
- Confirm logging commands live under :Flemma logging:*.
]]
```
Errors (syntax problems, unknown parser) block the request and show in a detailed notification with filename and line number.
Passive evaluation
Frontmatter is re-evaluated automatically whenever the buffer content changes — on InsertLeave, TextChanged, and BufEnter. This means integrations like lualine see up-to-date config values (model, thinking level, etc.) as you edit, without waiting for a send.
If a frontmatter edit introduces an error, the last successful parse is preserved — you can experiment freely without breaking your session mid-edit. Errors surface as diagnostics on the next :Flemma send or in the :Flemma status window.
Passive evaluation is skipped while a request is in flight (the active send owns the frontmatter state).
Custom frontmatter parsers
Lua and JSON parsers ship with Flemma. You can register additional parsers (e.g., YAML) with:
require("flemma.codeblock.parsers").register("yaml", function(code, context)
-- `code` is the raw fenced block content (string)
-- `context` is an optional table with __filename, __dirname, and user variables
-- Must return a table of variables; errors are caught and reported as diagnostics
return require("flemma.utilities.json").decode(vim.fn.system("yq -o json", code))
end)
Parsers are lazy-loaded on first use and cached for the session. See lua/flemma/codeblock/parsers/ for the built-in implementations.
Per-buffer overrides with flemma.opt
Lua frontmatter has access to a special flemma.opt proxy that lets you override configuration for the current buffer without touching your global setup. Changes made through flemma.opt only affect the request sent from that buffer.
Parameter overrides:
```lua
flemma.opt.thinking = "medium"
flemma.opt.temperature = 0.3
flemma.opt.max_tokens = 8000
flemma.opt.cache_retention = "none"
```
Provider-specific overrides:
```lua
flemma.opt.anthropic.thinking_budget = 20000
flemma.opt.openai.reasoning = "high"
flemma.opt.vertex.thinking_budget = 4096
```
Tool selection: The flemma.opt.tools proxy supports list operations and operator overloads for concise tool management:
```lua
-- Replace the tool list entirely (direct assignment)
flemma.opt.tools = { "bash", "read" }
-- Add or remove individual tools
flemma.opt.tools:append("grep")
flemma.opt.tools:remove("write")
flemma.opt.tools:prepend("bash")
-- Operator shorthand: + (append), - (remove), ^ (prepend)
flemma.opt.tools = flemma.opt.tools + "grep" - "write" ^ "bash"
```
Per-buffer auto-approval: Override the global approval policy for this buffer. Presets, tool names, and ListOption operations all work here:
```lua
-- Preset form: read-only access for this buffer
flemma.opt.tools.auto_approve = { "$readonly" }
-- List form: auto-approve these tools, require approval for the rest
flemma.opt.tools.auto_approve = { "bash", "read" }
-- ListOption operations: modify the default policy incrementally
flemma.opt.tools.auto_approve = { "$standard" }
flemma.opt.tools.auto_approve:remove("write") -- exclude write from $standard
flemma.opt.tools.auto_approve:append("bash") -- add bash on top
-- Operator shorthand: + (append), - (remove)
flemma.opt.tools.auto_approve = flemma.opt.tools.auto_approve + "bash" - "write"
-- Function form: full control over the decision
flemma.opt.tools.auto_approve = function(tool_name, input, context)
if tool_name == "grep" then return true end
return nil -- defer to global config
end
```
Removing a tool that lives inside a preset (e.g., "write" from { "$standard" }) creates an exclusion – the tool is filtered out when the preset expands, without affecting other tools in the preset.
Per-buffer autopilot: Disable (or force-enable) autopilot for a specific buffer:
```lua
flemma.opt.tools.autopilot = false -- manual three-phase Ctrl-] for this buffer
```
If you misspell a tool name, Flemma suggests the closest match: "flemma.opt: unknown value 'raed'. Did you mean 'read'?".
Only options you actually touch appear in the resolved overrides – unmodified settings fall through to your global config. See docs/tools.md for more on tool approval and the resolver API.
Op-prefix syntax for list values
For list-valued config fields, single-character prefixes on individual items compose set, append, prepend, remove, and preset spread into a single literal. These work everywhere a list value can appear — setup() config, Lua flemma.opt, JSON frontmatter — without needing to switch syntactic gears mid-list:
| Prefix | Effect | Example |
|---|---|---|
+ | Append item | "+bash" |
^ | Prepend item | "^bash" |
! | Remove item | "!write" |
$ | Spread a preset's items into place | "$standard" |
The four prefixes can be mixed freely inside a single list — they apply in declaration order:
-- setup() config: start from $standard, drop write, then append bash
tools = { auto_approve = { "$standard", "!write", "+bash" } }
-- Lua frontmatter: same idea, per-buffer
flemma.opt.tools.auto_approve = { "$readonly", "+bash" }
A bare value with no prefix is a set item (it replaces the list). Mixing bare values with ops works as expected — the bare values seed the list, then ops apply. Empty list {} means "set to empty list."
Operators are non-composable — +^bash, !$standard, and similar combinations are parse errors. The $ prefix only matches lowercase names so environment-variable-looking strings ($HOME, ${TMPDIR}) pass through as set items. Unknown preset references produce a "did you mean?" warning at finalize time.
Important
Op-prefixes are parsed only when assigning a list — never when calling a method. flemma.opt.tools = { "$standard", "+bash" } works; flemma.opt.tools:append("+bash") does not (the literal string "+bash" fails tool validation). The ListProxy method API has its own verbs for the same effect:
| Op-prefix form | Equivalent method call |
|---|---|
flemma.opt.tools = { existing, "+bash" } | flemma.opt.tools:append("bash") |
flemma.opt.tools = { existing, "^bash" } | flemma.opt.tools:prepend("bash") |
flemma.opt.tools = { existing, "!write" } | flemma.opt.tools:remove("write") |
flemma.opt.tools = { "$standard", "+bash" } | not expressible as a single method call |
Preset references ("$name") are the one exception — they're expanded by the schema coerce function rather than the listops parser, so :append("$standard") and :remove("$readonly") both work as expected.
JSON frontmatter
JSON frontmatter blocks can override Flemma configuration through a flemma key. Regular keys navigate into nested config objects; list-valued fields use the same op-prefix syntax documented above:
```json
{
"flemma": {
"provider": "openai",
"model": "gpt-5",
"parameters": {
"thinking": "medium",
"temperature": 0.3
},
"tools": {
"auto_approve": ["$standard", "!write", "+bash"]
}
},
"recipient": "QA team"
}
```
"parameters": { "thinking": "medium" } descends into the parameters node and sets thinking to "medium" without touching other parameters. Plain arrays without prefixes replace the list entirely (["read", "write"] is equivalent to ["+read", "+write"] starting from an empty list). The flemma key is reserved for configuration; all other top-level keys become template variables available in {{ expressions }}, just like Lua frontmatter.
Note
JSON frontmatter is the equivalent of Lua frontmatter's flemma.opt proxy. Both write to the same per-buffer config layer. Use whichever syntax you prefer — Lua frontmatter for full programmatic control, JSON frontmatter for quick declarative overrides.
Frontmatter config values are validated against the schema. Unknown keys produce an error; misspelled tool names get a "did you mean?" suggestion.
Inline expressions
Use {{ expression }} inside any @System: or @You: message. Expressions run in an environment built from registered populators that includes standard Lua libraries, select Neovim APIs, and variables from frontmatter. The built-in populators are defined in lua/flemma/templating/builtins/.
Key built-ins (from the stdlib populator):
__filename– the absolute path to the current.chatfile.__dirname– the directory containing the current file.include()– inline another file (see below).string,table– full standard libraries.math– a curated subset:abs,ceil,floor,max,min,random,randomseed,round(alias forfloor),pi.utf8– the standard library when running on Lua 5.3+ (nil under LuaJIT).os.date,os.time,os.clock,os.difftime– read-only time functions (noexecute,exit,getenv, etc.).vim.fn.fnamemodify,vim.fn.getcwd,vim.fn.filereadable,vim.fn.simplify,vim.fs.normalize,vim.fs.abspath– curated Neovim API surface for path resolution.assert,error,ipairs,pairs,pcall,select,tonumber,tostring,type,print,_VERSION– essential Lua globals.symbols.BINARY,symbols.MIME– opaque keys forinclude()binary mode.symbolsis reserved and cannot be reassigned from frontmatter.
The format populator (priority 150) adds format.number, format.tokens, format.money, and format.percent — used by the default statusline template and reusable from any expression. The iterators populator (priority 200) adds values() and each() — see Iterator helpers.
@You:
Draft a short update for {{recipient}} covering:
{{notes}}
Evaluation rules
- Expression bodies are unconditionally wrapped as
return (...), so{{ 1 + 1 }}becomesreturn (1 + 1)internally. Don't write your ownreturn—{{ return foo }}producesreturn (return foo), which is a syntax error. nilresults produce no output (empty string) — but note that accessing an undefined variable is an error, not nil (see strict variable checking below). Only variables that are explicitly defined with a nil value produce no output.- Tables are automatically JSON-encoded via
flemma.utilities.json.encode(). - Errors (including undefined variable access) are downgraded to warnings. The request still sends, and the literal
{{ expression }}remains in the prompt so you can see what failed. - Expressions are also re-evaluated passively while you edit — see Passive evaluation above. Treat side-effecting expressions accordingly:
math.random()will draw a fresh value on every redraw, and a populator that reads from disk will be hit on everyInsertLeave/TextChanged/BufEnter. - Expressions can suspend the send pipeline without losing the request. If an expression's populator raises
readiness.Suspense(e.g., waiting on a credential or an MCP discovery), Flemma catches the sentinel, subscribes to the boundary, and retries the pipeline when it resolves. See Architectural contracts for extension authors in the extension docs for the full pattern.
Template code blocks
Use {% code %} to embed Lua statements directly in your messages. Unlike {{ expressions }}, which output a value, code blocks execute statements -- control flow, variable assignment, loops -- without emitting output themselves. Use print() or __emit() inside code blocks when you need to output text.
@System:
{% if task == "review" then %}
You are a code reviewer. Be concise and direct.
{% else %}
You are a helpful assistant.
{% end %}
Control flow
Standard Lua if/elseif/else/end and for/while/repeat loops all work. Each {% %} block is a fragment of the same Lua chunk, so you can open a block in one tag and close it in another:
@You:
{% for item, loop in each(items) do %}
- Item {{loop.index}}: {{item}}
{% end %}
Variable assignment
Assign variables in code blocks and reference them in later expressions or code blocks within the same message:
@You:
{% label = string.upper(project) %}
Project: {{label}}
Use local when the variable is only needed within the current message. Without local, the variable is set on the shared environment and accessible from subsequent messages:
@System:
{% mode = "strict" %}
@You:
Mode is {{mode}}
Strict variable checking
The template environment errors when you access a variable that was never defined. This catches typos early — {{ mane }} when you meant {{ name }} will produce a diagnostic instead of silently inserting nothing.
The checking applies to all variable access: {{ mane }}, {{ string.upper(mane) }}, and {% if mane then %} all error if mane was never defined. Variables are considered "defined" if they were set by frontmatter, passed as include() arguments, or provided by a populator (the standard library, iterators, etc.).
In {{ expressions }}, undefined variable errors degrade gracefully like any other expression error — the raw {{ mane }} text is preserved in the output and a warning diagnostic is shown. In {% code %} blocks, undefined variable errors are fatal (just like any other code block error).
Error behaviour
Code block errors are fatal -- they block the request with a diagnostic showing the file and line number. This is different from {{ expressions }}, which degrade gracefully by emitting the raw expression text and sending the request with a warning. The distinction is intentional: a broken expression produces ugly but usable output, while a broken control flow structure (e.g., an if without end) would produce nonsensical output.
Whitespace trimming
By default, the literal text between template tags is preserved exactly -- including the newlines around {% %} and {{ }} tags. This often produces unwanted blank lines in the output. Trimming modifiers strip whitespace adjacent to a tag:
{%-or{{-trims whitespace before the tag (back to and including the nearest newline).-%}or-}}trims whitespace after the tag (up to and including the nearest newline).
Combine both for fully clean output.
Before and after
Without trimming:
@System:
{% if verbose then %}
Include full details.
{% end %}
Output (when verbose is true):
\n
Include full details.
\n
With trimming:
@System:
{%- if verbose then -%}
Include full details.
{%- end -%}
Output:
Include full details.
Trimming works on {{ }} expressions too. {{- value -}} strips surrounding whitespace, useful when an expression sits on its own line but the output should join adjacent text.
include() helper
Call include("relative/or/absolute/path") inside frontmatter or an expression to inline another template fragment. Includes support two modes:
Text mode (default) -- the included file is parsed for {{ }} expressions and {% %} code blocks (including nested include() calls), which are evaluated recursively. The result is inlined as text. Each included file gets its own __filename and __dirname, isolated from the parent's variables -- the parent's frontmatter variables are not inherited. Note that @./ file references are not desugared inside an included file (they are resolved only by the preprocessor, and only on top-level messages) -- to attach a file from within an include, use binary mode: {{ include('./path', { [symbols.BINARY] = true }) }}.
@System:
{{ include("system-prompt.md") }}
Binary mode -- the file is read as raw bytes and attached as a structured content part (image, PDF, etc.), just like @./path. Use the symbols.BINARY and symbols.MIME keys to control include mode:
-- In frontmatter:
screenshot = include('./latest.png', { [symbols.BINARY] = true })
@You:
What do you see? {{ screenshot }}
The symbols.BINARY flag and an optional symbols.MIME override are passed as symbol keys in the second argument:
include('./data.bin', { [symbols.BINARY] = true, [symbols.MIME] = 'text/csv' })
symbols.BINARY and symbols.MIME are opaque table references (not strings), so they never collide with user-defined string keys. All string keys in the second argument are template variables passed to the included file. The symbols table is a reserved environment key and must not be overwritten by frontmatter variables.
Argument passing
Pass variables to included files through the second argument. Keys become local variables in the child environment:
@System:
{{ include("greeting.md", { name = "Alice", role = "reviewer" }) }}
Inside greeting.md:
Hello {{name}}, you are acting as a {{role}}.
Included files have full template support at any nesting depth -- {% %} code blocks, {{ }} expressions, and nested include() calls all work. The child environment is isolated: it receives only the variables you pass (plus __filename and __dirname), not the parent's frontmatter variables.
Personality URNs
include() also accepts a urn:flemma:personality:<name> reference, which renders that personality's system prompt in place instead of reading a file:
@System:
{{ include("urn:flemma:personality:coding-assistant") }}
The personality is looked up in the registry and built with the current ambient state (buffer, working directory). An unknown name raises a diagnostic with a "did you mean?" suggestion.
Safety guards
- Relative paths resolve against the directory of the file that called
include(). - Circular includes are detected via an immutable stack threaded through each call. The error message is
"Circular include detected (requested by '<filename>')", where<filename>is the calling template's__filename. The full include stack is attached as a structuredinclude_stackfield on the diagnostic table — visible in the diagnostic UI but not formatted into the message string itself. - Missing files or read errors raise diagnostics that block the request.
- Binary includes skip circular detection since they don't recurse.
Iterator helpers
Flemma provides two iterator helpers for concise array iteration in templates:
values(t) — iterate over array values without the index variable:
{% for item in values(items) do %}
- {{ item }}
{% end %}
each(t) — iterate with a loop metadata context:
{% for item, loop in each(items) do %}
- Item {{ loop.index }} of {{ loop.length }}: {{ item }}
{% end %}
The loop table provides:
| Field | Description |
|---|---|
index | 1-based position |
index0 | 0-based position |
first | true for the first element |
last | true for the last element |
length | Total number of elements |
Extending the environment
The template environment is built from registered populators — functions that receive a table and populate it with globals. Flemma ships three built-in populators: stdlib (priority 100), format (priority 150), and iterators (priority 200).
Third-party populators are registered via templating.modules in setup:
require("flemma").setup({
templating = {
modules = { "my.custom.templating" },
},
})
Each module returns a table with name, priority, and populate:
-- my/custom/templating.lua
return {
name = "custom",
priority = 300,
populate = function(env)
env.my_helper = function() return "hello" end
env.os = nil -- remove something from an earlier populator
end,
}
Populators run in priority order (lower first). Later populators can override or remove anything set by earlier ones.
Diagnostics at a glance
Flemma groups diagnostics by type in the notification shown before sending:
- Frontmatter errors (blocking) – malformed code, unknown parser, include failures.
- Expression warnings (non-blocking) – undefined variables, runtime errors, or type errors during
{{ }}evaluation. The original expression text is preserved in the output. - File reference warnings (non-blocking) – missing files, unsupported MIME types, read errors.
All diagnostics include position information (line and column) for precise error location. If any blocking error occurs the buffer becomes modifiable again and the request is cancelled before hitting the network.
Referencing local files
Embed local context with @./relative/path (or @../up-one/path, @~/home/path, @//absolute/path). Flemma handles:
- Resolving the path against the
.chatfile's directory. - Detecting the MIME type via the
fileCLI or the extension fallback (seelua/flemma/mime.luafor the full extension map). - Formatting the attachment in the provider-specific structure.
@You:
Critique @./patches/fix.lua;type=text/x-lua.
@You:
OCR this screenshot @./artifacts/failure.png.
@You:
Compare these specs: @./specs/v1.pdf and @./specs/v2.pdf.
@You:
Check the logs at @//var/log/app.log.
Syntax details
- Path prefixes:
@./and@../resolve relative to the.chatfile's directory.@~/resolves relative to$HOME.@//denotes an absolute path (@//tmp/fileresolves to/tmp/file). - Trailing punctuation (
.,),,, etc.) is stripped automatically so you can write natural prose around references. - Options: the
;key=valuetail after a reference is parsed as matrix parameters (multiple;key=valuepairs are supported).type=<mime>is the only option consumed today — it forces a specific MIME type, as in the Lua example above. A MIME type that itself contains;parameters must be quoted —@./notes.txt;type='text/plain;charset=utf-8'— since an unquoted;starts the next option. - URL-encoded paths: percent-encoded characters are decoded before file resolution.
@./my%20report.txtresolves tomy report.txt.
Tip
Under the hood, @./path desugars to an include() call in binary mode. This means @./file.png and {{ include('./file.png', { [symbols.BINARY] = true }) }} are equivalent – you can use whichever reads better in context.
Provider support matrix
| Provider | Text files | Images | PDFs | Behaviour when unsupported |
|---|---|---|---|---|
| Anthropic | Embedded as plain text parts | Uploaded as base64 image parts | Sent as document parts | The literal @./path is kept and a warning is shown. |
| OpenAI | Embedded as text parts | Sent as image_url entries with data URLs | Sent as file objects | Unsupported types become plain text with a diagnostic. |
| Vertex AI | Embedded as text parts | Sent as inlineData | Sent as inlineData | Falls back to text with a warning. |
| Moonshot | Embedded as text parts | Sent as image_url entries with data URLs | Not supported (Chat Completions has no PDF part) | Unsupported types become plain text with a diagnostic. |
| Codex | Sent as input_text parts | Sent as input_image parts with data URLs | Sent as input_file parts | Unsupported types become plain text with a diagnostic. |
If a file cannot be read or the provider refuses its MIME type, Flemma warns you (including line number) and continues with the raw reference so you can adjust your prompt.