Flemma's tool system lets models request actions – run a calculation, execute a shell command, read or modify files – and receive structured results, all within the .chat buffer. This document covers approval, per-buffer configuration, custom tool registration, and the resolver API.
Tools are the primary model-facing surface of the harness: auto-injected parameters (flemma.background, flemma.save_to), the flemma.jobs.status polling tool, status suffixes, output truncation, and strict-schema rewriting are all documented in this file. The gating surface — approval resolvers, capability tags, and sandbox auto-approval — is also covered here and in sandbox.md.
For a quick overview of built-in tools and the basic workflow, see The Agent section in the README.
By default, Flemma requires you to review tool calls before execution. A single keypress (Ctrl-]) drives the entire flow through three phases:
Phase 1 – Categorize. When the model responds with **Tool Use:** blocks, pressing Ctrl-] checks each tool call against your approval settings and injects a **Tool Result:** placeholder with a status:
| Status | Meaning |
|---|---|
approved |
Auto-approved by policy; will execute immediately |
pending |
Requires your review; blocks the cycle until you act |
denied |
Blocked by policy; an error result is injected |
The cursor moves to the first pending placeholder so you can review it.
Phase 2 – Execute. On the next Ctrl-] (or automatically via vim.schedule when Phase 1 produced only approved/denied tools), Flemma processes each placeholder by status:
approved→ the tool executes and its output replaces the placeholder.denied→ an error result is injected (the model sees the tool was blocked).rejected→ an error result is injected, using any content you wrote inside the block as the error message.pending→ blocks the cycle. The cursor moves here and Flemma waits for you to act.
The model-facing result the rejection produces is explicit. With no content, a rejected tool injects the literal error string This tool has been rejected by the user.; a denied tool injects the policy denial message. When you supply a reason — through the rejection popup or :Flemma tool:reject <reason> — the reason is wrapped as User feedback: <reason> and written into the fence, and that exact text becomes the tool_result error string the model reads.
Phase 3 – Send. When no lifecycle-status placeholders remain (every tool has a real result), the next Ctrl-] sends the conversation to the provider.
With autopilot enabled (the default), Phases 1–3 chain automatically for approved tools. You only interact when a tool lands on pending.
Each placeholder carries its status in a parenthesized suffix on the **Tool Result:** header. The fenced block below the header is a plain code block — empty for pending placeholders, populated for errors or resolved results:
**Tool Result:** `toolu_01` (pending)
```
```
The suffix is modeline-parseable: a bare word like (pending) upgrades to status=pending; explicit keys like (status=pending sandbox=false) mix status with future metadata. Unrecognized tokens round-trip through the meta field on the tool_result AST node.
You can edit the status directly in the header. This is the primary way to interact with pending tools:
-
Approve: change
(pending)to(approved), then press Ctrl-]. -
Reject: change
(pending)to(rejected), then press Ctrl-]. Flemma injects an error result telling the model the tool was rejected. -
Reject with a message: change the status to
rejectedand type your reason inside the fence – the model sees your text as the error:**Tool Result:** `toolu_01` (rejected) ``` I don't want to run rm -rf on my home directory. ``` -
Execute one tool: press Alt-Enter on any tool block to execute or resolve it immediately (works for
approved,pending,rejected, anddenied). -
Approve via command or keymap:
:Flemma tool:approve(or Alt-A) toggles the tool under the cursor to(approved)without executing it – the next Ctrl-] runs it. -
Approve all pending:
:Flemma tool:approve-all(or Alt-Shift-A) approves every pending tool in the buffer at once. -
Reject via command or keymap:
:Flemma tool:reject(or Alt-R, which opens the inline rejection popup) toggles the tool under the cursor to(rejected). Append an optional message (:Flemma tool:reject do not run rm -rf) and it's written into the fence as the rejection reason the model will see.
If you paste or type output inside a pending block, Flemma treats it as a user-provided result: on Ctrl-] the (pending) suffix is cleared from the header and your content is sent to the model as a normal tool result. This is useful when you run a command manually and want to provide the output yourself.
If you edit the content inside an approved block, Flemma skips execution to protect your edits – a warning is shown and the cycle pauses so you can review. Clear the (approved) suffix from the header manually to send your content.
Out of the box, auto_approve is set to { "$standard" }, which auto-approves read, write, edit, find, grep, and ls while keeping bash gated behind manual approval. This gives you a working agent loop without opting out of safety for shell commands.
Disable approval entirely with tools.require_approval = false – this registers a catch-all resolver at priority 0 that auto-approves every tool call. Alternatively, use tools.auto_approve to build a custom policy with presets, tool names, or a function:
tools = {
-- Preset references and tool names can be mixed freely
auto_approve = { "$readonly", "bash" },
-- Function form for full control
auto_approve = function(tool_name, input, ctx)
if tool_name == "grep" then return true end
if tool_name == "bash" and input.command:match("rm %-rf") then return "deny" end
return false -- require approval
end,
}Entries containing * are treated as glob patterns — "flemma.*" matches any tool whose name starts with flemma.. This is how the built-in $standard preset auto-approves all harness tools.
Presets are named collections of tool approval rules referenced with a $ prefix in auto_approve. They keep common policies concise and composable.
Built-in presets:
| Preset | Approves | Description |
|---|---|---|
$readonly |
read, find, grep, ls |
Read-only access – safe for exploration buffers |
$standard |
read, write, edit, find, grep, ls, flemma.* |
File operations and harness tools, without shell access (the default) |
User-defined presets override built-ins by name. Define them in presets:
presets = {
["$yolo"] = { auto_approve = { "bash", "read", "write", "edit" } },
},
tools = {
auto_approve = { "$yolo" },
}Each preset is a table with an auto_approve array — the tool names to auto-approve.
Composition rules:
- Union. When multiple presets appear in
auto_approve, theirapprovesets are merged. - Plain tool names mix freely with presets:
{ "$standard", "bash" }approves everything in$standardplusbash.
Override approval on a per-buffer basis using flemma.opt.tools.auto_approve in Lua frontmatter. The unified config resolver reads the merged value from all layers, so frontmatter values take precedence over global config automatically — there's no separate resolver to coordinate:
```lua
-- Preset form: read-only access for this buffer
flemma.opt.tools.auto_approve = { "$readonly" }
-- List form: auto-approve these tools in this buffer
flemma.opt.tools.auto_approve = { "bash", "read" }
-- Mix presets and tool names
flemma.opt.tools.auto_approve = { "$standard", "bash" }
-- Function form: full control per-buffer
flemma.opt.tools.auto_approve = function(tool_name, input, ctx)
if tool_name == "grep" then return true end
return nil -- pass to next resolver
end
```The function form returns true (approve), false (require approval), "deny" (block), or nil (pass to the next resolver in the chain).
ListOption operations let you modify the default policy incrementally instead of replacing it:
```lua
-- Start from default, but remove write access
flemma.opt.tools.auto_approve = { "$standard" }
flemma.opt.tools.auto_approve:remove("write")
-- Add bash to the default set
flemma.opt.tools.auto_approve = { "$standard" }
flemma.opt.tools.auto_approve:append("bash")
-- Operator shorthand: + (append), - (remove)
flemma.opt.tools.auto_approve = flemma.opt.tools.auto_approve + "bash" - "write"
```When you :remove() a tool that lives inside a preset (e.g., removing "write" from { "$standard" }), the tool is excluded at expansion time – the preset itself stays in the list, but the named tool is filtered out when the resolver evaluates it.
- Async tools (like
bash) show an animated spinner while running and can be cancelled. - Buffer locking – the buffer is made non-modifiable during tool execution to prevent race conditions.
- Output truncation – large outputs (> 2000 lines or 50KB) are automatically truncated. The full output is saved to the tool result store and the truncated content ends with a notice pointing at it.
- Cursor positioning – after injection, the cursor can move to the result (
"result"), stay put ("stay"), or jump to the next@You:prompt ("next"). Controlled bytools.cursor_after_result.
On Neovim 0.12+ the bash tool executes commands inside a hidden terminal buffer rather than a raw jobstart process. This gives the command a real TTY so colour codes, progress bars, and curses-style tools render as the model would see them in a normal shell. Output is captured up to Neovim's compiled maximum scrollback (1M lines on 0.12+) before truncation kicks in. On Neovim 0.11 the tool falls back to jobstart since terminal buffers were not safe to drive from background contexts in that release.
This is transparent to your config — there's nothing to opt into. The terminal buffer is hidden, named after the tool invocation, and deleted once the command exits.
All supported providers handle parallel tool calls. Press Ctrl-] to execute all pending calls at once, or use Alt-Enter on individual blocks. Flemma validates that every **Tool Use:** block has a matching **Tool Result:** before sending.
tools.max_concurrent (default 2) limits how many tools execute simultaneously per buffer. When the model returns more tool calls than the concurrency limit allows, Flemma queues the excess and starts them as earlier tools complete. This prevents resource exhaustion when the model emits many parallel calls.
Set tools.max_concurrent = 0 for unlimited concurrency. Override per-buffer via flemma.opt.tools.max_concurrent in frontmatter.
Async tools can run in the background without blocking the conversation. The tool continues executing while the model responds to other tool results or the user types a new message. When the job completes, its result is queued and delivered as a **Job Result:** block in an @You message.
Model-initiated. Flemma injects a flemma.background boolean parameter into every async tool's schema (a harness parameter, invisible to the tool itself). When the model sets "flemma.background": true, the tool executes in the background from the start — the tool_result placeholder receives a job ID and a placeholder message, and the conversation continues immediately. The parameter description (key tool.parameter.background) encourages foreground by default; the model should only background a tool when it has other meaningful work to do while waiting and no upcoming decision depends on the result.
User-initiated. Press Alt-B (or :Flemma tool:background) while the cursor is on an executing tool to move it to background mid-flight. The tool keeps running, but the buffer unlocks and the conversation advances. If all foreground tools are now clear, autopilot triggers the next send automatically.
Completed background jobs are queued until one of two delivery points:
- Conversation idle – after the model's response contains no tool calls, queued results are injected.
- User send – when the user presses Ctrl-] to send a new message, queued results are drained first.
Results appear as **Job Result:** `job_xxx` blocks inside @You messages. If the user is typing in the current @You block, the job result is inserted above in a separate @You block to avoid disrupting their work. Multiple results from different jobs merge into the same @You block when possible. Background tool_result placeholders are kept open (not auto-folded) while their job is still running, so the placeholder text and spinner remain visible.
Important
Some models hallucinate pending job results. When early jobs complete and their results are delivered before later jobs finish, the model sees a pattern — some results present, others pending — and may "complete" it by generating fabricated job output as plain text in its response. The hallucinated text can be convincingly detailed: plausible command output, tool IDs that mimic the real format, and formatting that resembles (but doesn't match) the actual **Job Result:** syntax.
This has been observed with Gemini Flash 3.5 but can occur with any model prone to pattern completion over instruction following. The key thing to remember is that job results can only appear in @You: messages — jobs execute on your machine, and Flemma delivers their output on your behalf. Any reference to job results in an @Assistant: message is purely fabricated. The real results always arrive later as proper **Job Result:** blocks in @You: messages.
When autopilot is enabled and background jobs complete at idle, Flemma schedules a debounced auto-continue after tools.autopilot.resume_delay milliseconds (default 2000). This gives you time to review the result before the model sees it. Press Ctrl-C during the delay to cancel. Entering insert mode during the delay also cancels auto-continue. When completions arrive during an active autopilot loop (not at idle), the delay is skipped and the conversation continues immediately.
A built-in harness tool that lets the model query the status of a background job by its job_id. Returns one of:
| Status | Meaning |
|---|---|
running |
The tool is still executing |
completed (delivery pending) |
Execution finished; the result is queued and will be injected automatically — don't re-run or poll |
completed |
Result already delivered into the buffer |
completed (removed from conversation) |
Job existed but its result block was deleted |
The tool cross-checks in-memory state against the buffer AST, so even if the process state is cleared (e.g., after a restart), it falls back to scanning for **Job Result:** blocks.
When reopening a .chat file that contains tool_result placeholders with a job=job_xxx modeline but no matching **Job Result:** block (e.g., after a crash or forced quit), Flemma detects these orphans on buffer load (BufRead/BufNewFile) and injects error results so the model knows the job was lost. A notification reports how many orphans were resolved.
When lsp.enabled is set (defaults to true whenever vim.lsp is available), job-related blocks gain hover and go-to-definition support:
- Hover on a
tool_resultwith ajob=modeline shows the job's current status (pending,completed, orerror) above the standard AST dump. - Go-to-definition (
gd) on atool_resultwith ajob=modeline jumps to the corresponding**Job Result:**block. On a**Job Result:**block,gdjumps back to the originatingtool_result.
Declare the "disables_background" capability on a tool definition to prevent the flemma.background parameter from being injected into its schema. Sync tools never receive the parameter regardless. The flemma.jobs.status harness tool declares this capability. Similarly, "disables_save_to" prevents flemma.save_to injection — harness tools declare both since their output is ephemeral coordination metadata.
Large tool outputs need a durable home outside the buffer. The store is a per-conversation directory — co-located with the .chat file by default — where Flemma writes full tool outputs. Three things write to it:
- Truncation overflow — when a result exceeds the truncation limits (2000 lines / 50KB), the truncated content injected into the buffer ends with a
Full output: <path>notice pointing at the complete output in the store. Always on; nothing to enable. flemma.save_toredirects — the model can ask for any tool's output to be written to a file instead of injected into the conversation (see below).- Eager materialization — with
tools.store.materialize = true, every tool and job result is written to the store, truncated or not. Defaults tofalse: on tool-heavy conversations this duplicates content that already lives in the buffer.
tools.store.path_format controls where results are written. It accepts a preset name or a template string:
| Preset | Expands to |
|---|---|
$chat (default) |
{{ __dirname }}/.flemma/{{ flemma.path.basename(__filename) }}/{{ source }}_{{ name }}_{{ id }}.txt |
$state |
${XDG_STATE_HOME:-$HOME/.flemma}/flemma/store/{{ flemma.path.flatten(__filename) }}/{{ source }}_{{ name }}_{{ id }}.txt |
With the default $chat preset, a truncated bash result in ~/notes/example.chat lands in ~/notes/.flemma/example.chat/tool_bash_toolu_01.txt — the store travels with the conversation. $state keeps chat directories clean by routing everything under $XDG_STATE_HOME/flemma/store/ instead (~/.flemma/store/ when XDG_STATE_HOME is unset), with the chat path flattened into a single directory name (home--user--notes--example.chat).
Custom templates render with the standard templating environment plus:
| Variable | Value |
|---|---|
{{ source }} |
"tool" for foreground results, "job" for background job deliveries |
{{ name }} |
The tool name, wire-encoded (dots become __, e.g. flemma__jobs__status) |
{{ id }} |
The tool/job ID, escaped — characters outside [A-Za-z0-9._-] become -- |
{{ __filename }} / {{ __dirname }} |
Chat file path / directory |
{{ bufnr }} |
Buffer number |
flemma.path.* |
Path helpers (basename, flatten, …) |
After template rendering, $VAR / ${VAR:-default} environment expansion applies, doubled flemma namespace segments collapse (.flemma/flemma/ → .flemma/), and a tool name repeated within a single path segment is de-duplicated — IDs that embed the tool name don't produce tool_bash_bash--… filenames.
Unsaved buffers have no chat path to derive from, so they use tools.store.unnamed_path_format instead (default: ${TMPDIR:-/tmp}/flemma/unnamed/{{ flemma.pid }}/{{ bufnr }}/{{ source }}_{{ name }}_{{ id }}.txt — the process ID keeps concurrent Neovim instances apart). Presets are not valid there.
Note
The store replaces the tools.truncate.output_path_format option from v0.12 and earlier. Truncation overflow now follows tools.store.path_format — by default landing next to the chat file instead of in $TMPDIR.
Re-running a tool writes to the same store path. Before overwriting, the configured backup strategy runs — the default version strategy renames the existing file to the next free <stem>.<n>.<ext> (e.g. tool_bash_toolu_01.1.txt), so the canonical path always holds the latest run. Set tools.store.backup = false to overwrite in place, or pass a Lua module path exporting backup(path) for a custom strategy.
Flemma injects an optional flemma.save_to string parameter into every tool's schema. When the model supplies a path, the full output is written there and the conversation receives a stub instead: a short head preview (tools.store.preview, default 10 lines / 2KB) followed by [Output saved: /path/to/file — 1.2MB, 54321 lines]. The parameter description (key tool.parameter.save_to) tells the model to reach for it when output is large or needs to live at a specific path.
- Relative paths resolve against the chat file's directory; absolute and
~/…paths work too. $FLEMMA_TOOLS_STORE_PATH/<filename>targets the conversation's store directory.- An existing file at the destination is backed up first through
tools.store.backup(the defaultversionstrategy renames it to the next free<stem>.<n>.<ext>), so a redirect never silently destroys prior content — settools.store.backup = falseto overwrite in place. - With the sandbox enabled, the destination must be writable under the sandbox policy.
- Redirects apply only to successful results, and work for both foreground results and background job deliveries.
- If the redirect fails (destination is a directory, sandbox denies the write, …), the full output is injected as if
flemma.save_tohad not been set, with an explanatory notice appended so the model knows the path it asked for was not written:[Output not saved: <reason>. Showing the full output instead.].
The bash tool exports $FLEMMA_TOOLS_STORE_PATH into every command's environment, pointing at the conversation's store directory. Shell commands can write artefacts directly there (curl -o "$FLEMMA_TOOLS_STORE_PATH/response.json" …), and the same variable works inside flemma.save_to values. The store directory is granted read-write access in the default sandbox policy via the urn:flemma:store variable (see docs/sandbox.md).
flemma.background and flemma.save_to are harness parameters: Flemma injects them into each tool's schema when serializing the prompt and strips them from the input before the tool's execute runs. Tool implementations never see them, and the namespaced names can't collide with real tool parameters. For strict-mode tools they are injected as nullable (type: [t, "null"]) and appended to required, preserving strict-schema invariants across providers.
tools = {
store = {
path_format = "$chat", -- "$chat", "$state", or a template string
unnamed_path_format = "${TMPDIR:-/tmp}/flemma/unnamed/{{ flemma.pid }}/{{ bufnr }}/{{ source }}_{{ name }}_{{ id }}.txt",
materialize = false, -- Write every result to the store, not just overflow and redirects
preview = {
lines = 10, -- Preview lines shown in the buffer for flemma.save_to redirects (0 = no preview)
bytes = 2048, -- Preview size cap
},
backup = "version", -- Backup strategy before overwriting (false to disable)
},
}All options can be overridden per-buffer via flemma.opt.tools.store in frontmatter.
When a tool call is pending approval, its tool_result placeholder fence is empty – you'd normally need to scroll up to the **Tool Use:** block to see what the tool will do. Tool previews eliminate that: Flemma renders a virtual line inside each empty placeholder showing a compact summary of the tool call.
For example, a pending read tool might show:
read: src/config.lua +0,50 — checking config
And a pending bash tool:
bash: $ make test — running tests
Each preview has two parts: a detail (the raw technical summary — path, command, pattern) and a label (the LLM's stated intent, taken from the tool call's label field), separated by —. Detail leads because it is the part you scan for to identify the call; label trails as human-readable context. When the available width is limited, detail is truncated first (with …), preserving the label. Previews are non-editable virtual text (extmarks) that disappear once the tool executes and its result replaces the placeholder.
Folded message previews use the same detail — label layout but render label and detail as separate highlight chunks. The virt-line placeholder preview is a single string with one combined highlight — see Styling.
When ui.approval.syntax_highlighting is enabled (the default), preview content receives treesitter syntax highlighting — JSON tool inputs and YAML-style generic previews render with proper syntax colours.
Every built-in tool ships with a tailored format_preview function that returns a structured { label, detail } preview:
| Tool | Label source | Detail format | Example |
|---|---|---|---|
bash |
input.label |
$ command |
bash: $ git status — checking repo |
read |
input.label |
Path with optional +offset[,limit] |
read: config.lua +100,50 — reading tail |
edit |
input.label |
Path | edit: config.lua — fixing typo |
write |
input.label |
Path with content size | write: output.txt (2.3KB) — saving log |
grep |
input.label |
/pattern/ with optional path/glob |
grep: /TODO/ *.lua — finding TODOs |
find |
input.label |
Pattern with optional search path | find: *.test.lua in src/ — finding tests |
ls |
input.label |
Path with optional depth | ls: src/ depth=3 — exploring structure |
Every built-in tool's input_schema includes a required label field — the LLM is prompted to supply a short intent string when it makes the call. The double-space gap in the read/write/grep/find/ls examples comes from joining the detail array (see return shapes below).
Tools without a format_preview function get a YAML-style generic preview. Scalar values appear as key: value pairs and compound values are JSON-encoded. When the inline rendering ({key: "value", ...}) fits within the available width, it displays on a single line; otherwise it splits into a multi-line block mapping — for example, tool_name: {query: "search term", limit: 10}. If the input table contains a string label field, it is auto-promoted to the label slot and the rendering becomes tool_name: {kv_body} — label. This auto-promotion only applies to the generic fallback — when a tool ships its own format_preview, that function controls label entirely.
Register a format_preview function on your tool definition to control how it appears in pending placeholders. The function returns a flemma.tools.ToolPreview, which is either a structured { label?, detail? } table or a plain string:
local s = require("flemma.schema")
local tools = require("flemma.tools")
tools.register("my_search", {
name = "my_search",
description = "Search a knowledge base",
strict = true,
input_schema = s.object({
label = s.string():describe("A short human-readable label for this operation"),
query = s.string():describe("Search query"),
limit = s.number():nullable():describe("Max results (default 10)"),
}):strict(),
format_preview = function(input)
return {
label = input.label,
detail = input.limit and ("limit " .. input.limit) or nil,
}
end,
execute = function(input, ctx) --[[ ... ]] end,
})The full type signature is fun(input: table, max_length: integer): flemma.tools.ToolPreview. max_length is the available width after the "name: " prefix is rendered — but the surrounding code already truncates the returned strings to fit, so built-in tools all ignore the argument and simply do function(input).
| Return type | Behaviour |
|---|---|
string |
Treated as { detail = the_string }. Label is never auto-promoted from input.label for explicit returns — only the generic fallback does that. |
{ label?, detail? } |
Rendered as detail — label when both are present; otherwise just whichever is given. |
{ label?, detail = string[] } |
The detail array is joined with a double space before display. |
{ ..., highlight? } |
When highlight = { lang = "yaml" } (or another treesitter language), the preview receives syntax highlighting if ui.approval.syntax_highlighting is enabled. The generic fallback uses this for YAML-style previews. |
Newlines in either field are collapsed to the eol character from listchars (or ↵ by default) and the result is truncated to fit the editor width.
Tool previews use three highlight groups:
| Group | Default | Applies to |
|---|---|---|
FlemmaToolPreview |
Comment |
The whole virt-line placeholder preview (single combined highlight) |
FlemmaToolLabel |
tool_preview color + italic |
The label chunk in folded message previews and the approved tool-result footer |
FlemmaToolDetail |
Comment |
The detail chunk inside folded message previews |
The virt-line placeholder preview is rendered as a single string with FlemmaToolPreview blended over the role's line background — so label and detail share one highlight and there is no italic distinction in that view. The label/detail split (the label adds italic over the same muted color) is only visible in folded message previews, where each chunk gets its own highlight.
Customise FlemmaToolDetail via highlights.tool_detail in your config. FlemmaToolLabel is the preview color (highlights.tool_preview) with the highlights.tool_label accent merged on top — italic by default; set a fg in highlights.tool_label to recolor it. See docs/ui.md for details.
Three of Flemma's built-in tools — grep, find, and ls — handle codebase exploration alongside read, edit, write, and bash:
| Tool | Type | Description |
|---|---|---|
grep |
async | Search file contents using ripgrep (rg), GNU grep with PCRE (grep -P), or POSIX ERE (grep -E) – whichever is available. |
find |
async | Find files by glob pattern using fd, git ls-files, or GNU find – whichever is available. |
ls |
sync | List directory contents with configurable recursion depth and entry limit. Directories appear first (suffixed with /). |
All three are included in the $standard and $readonly approval presets, so they are auto-approved by default when using either preset.
Each tool has an optional config section under tools:
tools = {
grep = {
cwd = "urn:flemma:buffer:path", -- working directory
exclude = { ".git", "node_modules", "__pycache__", -- patterns to exclude
".venv", "target", "dist", "build", "vendor" },
},
find = {
cwd = "urn:flemma:buffer:path",
exclude = { ".git", "node_modules", "__pycache__",
".venv", "target", "dist", "build", "vendor" },
},
ls = {
cwd = "urn:flemma:buffer:path",
},
}grep and find auto-detect the best available backend at first use and cache the result:
| Tool | Priority 1 | Priority 2 | Priority 3 |
|---|---|---|---|
grep |
rg (ripgrep, JSON mode) |
grep -P (GNU grep with PCRE) |
grep -E (POSIX ERE) |
find |
fd / fdfind |
git ls-files |
GNU find |
When using the grep -E fallback, Perl-style shorthand classes (\d, \w, \s) are automatically translated to POSIX equivalents.
Control which tools are available per-buffer using flemma.opt in Lua frontmatter:
```lua
flemma.opt.tools = {"bash", "read"} -- only these tools
flemma.opt.tools:remove("write") -- remove from defaults
flemma.opt.tools:append("grep") -- add a tool
flemma.opt.tools = flemma.opt.tools + "read" -- operator overloads work too
```Each evaluation starts from defaults (all enabled tools). Misspelled tool names produce an error with a "did you mean" suggestion.
Entries containing * are treated as glob patterns and expanded against the registered tools. This is the natural way to bulk-enable namespaced tools (MCP servers, custom harness tools):
```lua
-- Enable every Slack tool that the MCPorter discovery surfaced
flemma.opt.tools:append("slack.*")
-- Replace defaults with just GitHub + Linear search tools
flemma.opt.tools = { "github.search_*", "linear.search_*" }
-- Drop a noisy MCP server's tools from this buffer
flemma.opt.tools:remove("linear.*")
```Globs work with all list operations (set, append, prepend, remove). Expansion runs in two passes:
- Write-time (best-effort) — when you assign or
:append/:removea glob, the schema coerce tries to substitute matching tool names immediately. If the tool registry is still loading (e.g. MCPorter discovery hasn't returned), the glob is stored verbatim for now. - Finalize-time (authoritative) — after setup completes and all async tool sources have resolved, every stored op is re-run through the coerce so verbatim globs get their second chance. Then the deferred validator runs: a pattern that still matches no registered tools at this point fails with
"Glob pattern 'x' matched no tools".
This is why typos in flemma.opt.tools surface as errors rather than silent no-ops — the finalize pass guarantees the tool registry is fully populated before validation decides anything.
Note
tools.auto_approve uses a different mechanism. Globs in the auto-approval list (and in $standard's "flemma.*" entry) are stored verbatim, never expanded by the config layer, and matched against incoming tool calls at approval time in the resolver chain (lua/flemma/tools/approval.lua:202). The end result is similar — { "flemma.*" } approves every harness tool — but no deferred validator runs over the list, so a typo in auto_approve (e.g. "flmma.*") silently approves nothing instead of erroring. Use :Flemma status verbose to see the resolved list and confirm your pattern is reaching the resolver.
General and provider-specific parameters can be overridden per-buffer using flemma.opt in Lua frontmatter:
```lua
-- General parameters (work across all providers)
flemma.opt.thinking = "medium" -- override the unified thinking level
flemma.opt.cache_retention = "long" -- override prompt caching strategy
flemma.opt.max_tokens = 8000 -- override max output tokens
flemma.opt.temperature = 0.3 -- override sampling temperature
-- Provider-specific overrides (take priority over general)
flemma.opt.anthropic.thinking_budget = 20000
flemma.opt.openai.reasoning = "high"
flemma.opt.vertex.thinking_budget = 4096
```When both general and provider-specific parameters are set, provider-specific values win. For example, setting both flemma.opt.thinking = "low" and flemma.opt.anthropic.thinking_budget = 20000 will use 20,000 tokens on Anthropic.
require("flemma.tools").register() is a single entry point that accepts several forms:
Single definition – pass a name and definition table:
local s = require("flemma.schema")
local tools = require("flemma.tools")
tools.register("my_tool", {
name = "my_tool",
description = "Does something useful",
strict = true,
input_schema = s.object({
label = s.string():describe("A short human-readable label for this operation"),
query = s.string():describe("The input query"),
}):strict(),
format_preview = function(input)
return { label = input.label, detail = '"' .. input.query .. '"' }
end,
execute = function(input, ctx)
return { success = true, output = "done: " .. input.query }
end,
})input_schema accepts either a flemma.schema.Node (built with s.object, s.string, …) or a raw JSON Schema table. Every built-in uses the DSL — it serializes to JSON Schema lazily on request, gives you EmmyLua-friendly types, and makes strict mode effectively free. See Tool input schemas with the schema DSL below for the full surface.
Module name – pass a module path. If the module exports .definitions (an array of definition tables), they are registered synchronously. If it exports .resolve(register, done), it is registered as an async source (see Async tool definitions):
tools.register("my_plugin.tools.search")Note
Built-in tool modules moved from flemma.tools.definitions.* to flemma.tools.definitions.builtin.* in v0.12, and the jobs harness tool lives at flemma.tools.definitions.harness.jobs. If you require() a built-in tool module by path, update the import.
Batch – pass an array of definition tables:
tools.register({
{
name = "tool_a",
description = "...",
strict = true,
input_schema = s.object({ label = s.string() }):strict(),
execute = function(input, ctx) --[[ ... ]] end,
},
{
name = "tool_b",
description = "...",
strict = true,
input_schema = s.object({ label = s.string() }):strict(),
execute = function(input, ctx) --[[ ... ]] end,
},
})The flemma.schema module exposes a chainable DSL for declaring tool inputs:
| Factory | Notes |
|---|---|
s.string() / s.number() / s.integer() / s.boolean() |
Scalars. Optional positional default: s.string("hello"). |
s.object({...}) |
Object schema. Strict by default — additionalProperties = false, all listed fields required. |
s.list(item) |
JSON Schema array. (s.array does not exist — use s.list.) |
s.map(key, value) |
Open object — JSON Schema additionalProperties with typed values. |
s.enum({"a", "b"}) |
Enumerated string. |
s.union(a, b, ...) |
anyOf. |
s.literal(value) |
Exact-value match. |
s.optional(inner) |
Field may be absent (omitted from required). |
s.nullable(inner) |
Field is present but its value may be null. Required in JSON Schema; type becomes [t, "null"]. |
Chainable methods on every schema node:
| Method | Effect |
|---|---|
:describe(text) |
Sets the description shown to the model. |
:nullable() |
Sugar for s.nullable(self). The idiomatic way to mark a tool parameter as optional in strict mode. |
:optional() |
Sugar for s.optional(self). The field may be absent — only valid outside strict mode. |
Object-specific:
| Method | Effect |
|---|---|
:strict() |
Marks the object as strict (additionalProperties = false). This is the default for s.object — every built-in keeps the call as an explicit readability marker. |
:passthrough() |
Inverse of :strict() — allow unknown keys. |
Strict-mode invariants the docs used to spell out by hand are handled automatically: fields wrapped in s.optional(...) are excluded from required, all others are required in sorted order, and additionalProperties = false is emitted when the object is strict.
Every tool's execute function receives up to three arguments: input, context, and an optional callback. The context is an ExecutionContext object that provides the stable contract tools code against – tools should never require() internal Flemma modules directly.
-- Sync tools: return an ExecutionResult directly
execute = function(input, ctx)
return { success = true, output = "done" }
end
-- Async tools: call callback(result) when done, return a cancel function
execute = function(input, ctx, callback)
-- ...
end| Field | Type | Description |
|---|---|---|
ctx.bufnr |
integer |
Buffer number for the current execution |
ctx.tool_id |
string? |
The tool-call ID — the same string that appears in the **Tool Use:** `name` (`tool_id`) header. Buffer-unique; use it as a correlation key for scratch files, log tags, or any per-call artefact you need to namespace. Optional in the type but always populated during real execution. |
ctx.cwd |
string |
Absolute working directory (resolved from config or Neovim) |
ctx.timeout |
integer |
Default timeout in seconds (from config.tools.default_timeout) |
ctx.__dirname |
string? |
Directory containing the .chat buffer (nil for unsaved) |
ctx.__filename |
string? |
Full path of the .chat buffer (nil for unsaved) |
The following namespaces are lazy-loaded on first access (zero cost if unused):
local absolute = ctx.path.resolve("relative/file.txt")
-- Resolves against __dirname (or cwd if buffer is unsaved)
-- Absolute paths pass through unchanged-- Check if a path is writable under the current sandbox policy
if not ctx.sandbox.is_path_writable(path) then
return { success = false, error = "Sandbox: path not writable" }
end
-- Wrap a command for sandbox enforcement (returns nil + error on failure)
local wrapped_cmd, err = ctx.sandbox.wrap_command({ "bash", "-c", "echo hello" })-- Truncate from the end (keep last N lines/bytes) – use for streaming output
local result = ctx.truncate.truncate_tail(full_output)
-- result.content, result.truncated, result.total_lines, result.output_lines, ...
-- Truncate from the start (keep first N lines/bytes) – use for file reads
local result = ctx.truncate.truncate_head(content)
-- Tail-truncate with automatic overflow handling: when truncation occurs, the
-- full output is saved to the tool result store (named with this tool call's
-- id) and a "Full output: /path/..." notice is appended to the returned
-- content. This is what `bash` uses for its streaming output.
local result = ctx.truncate.truncate_with_overflow(full_output, { direction = "tail" })
-- Format byte counts for display
local size_str = ctx.truncate.format_size(12345) -- "12.1KB"
-- Constants
ctx.truncate.MAX_LINES -- 2000
ctx.truncate.MAX_BYTES -- 51200 (50KB)Returns a read-only copy of config.tools[tool_name], or nil if no config subtree exists for this tool. The returned table is a deep copy – modifications do not affect the global config.
local tool_config = ctx:get_config()
if tool_config and tool_config.shell then
-- Use configured shell
endReturns the cached flemma.ast.DocumentNode for ctx.bufnr. Use it when a tool needs to inspect the surrounding conversation structure — for example, the flemma.jobs.status harness tool cross-checks in-memory state against **Job Result:** segments in the buffer.
local doc = ctx:get_parsed_document()
for _, msg in ipairs(doc.messages) do
-- inspect roles, segments, tool calls...
endReach for this only when buffer truth matters — most tools should operate on input and filesystem state, not the conversation transcript.
local s = require("flemma.schema")
local tools = require("flemma.tools")
tools.register("export", {
name = "export",
description = "Save content to a file in the project",
strict = true,
async = false,
input_schema = s.object({
label = s.string():describe("A short human-readable label (e.g., 'saving log')"),
path = s.string():describe("Output file path (relative or absolute)"),
content = s.string():describe("Content to write"),
}):strict(),
format_preview = function(input)
return {
label = input.label,
detail = { input.path, "(" .. #input.content .. "B)" },
}
end,
execute = function(input, ctx)
local path = ctx.path.resolve(input.path)
if not ctx.sandbox.is_path_writable(path) then
return { success = false, error = "Sandbox: write denied for " .. input.path }
end
-- Use tool-specific config (e.g. config.tools.export = { max_size = 102400 })
local tool_config = ctx:get_config()
local max_size = (tool_config and tool_config.max_size) or ctx.truncate.MAX_BYTES
if #input.content > max_size then
return {
success = false,
error = "Content exceeds " .. ctx.truncate.format_size(max_size) .. " limit",
}
end
-- ... write logic ...
return { success = true, output = "Saved " .. #input.content .. " bytes to " .. input.path }
end,
})OpenAI's Responses API supports strict mode for function calling, which guarantees that the model's arguments will conform exactly to your JSON Schema. All of Flemma's built-in tools use strict mode.
To opt in for your custom tools, set strict = true on the definition and build the input_schema with s.object(...):strict(). The DSL handles every strict-mode invariant automatically:
- Every field listed on the object is added to
requiredin sorted order — wrap a field ins.optional(...)to exclude it. additionalProperties = falseis emitted when the object is strict (which is the default fors.object).- Optional parameters use
:nullable(), which produces atype: [t, "null"]shape on the JSON Schema side. The field stays inrequired.
local s = require("flemma.schema")
tools.register("my_tool", {
name = "my_tool",
description = "Does something",
strict = true,
input_schema = s.object({
query = s.string():describe("Required input"),
max_results = s.number():nullable():describe("Optional limit (default: 10)"),
}):strict(),
execute = function(input, ctx)
local limit = input.max_results or 10
return { success = true, output = "found results" }
end,
})When strict is not set (or set to false), the field is omitted from the API request entirely. You can still pass a raw JSON Schema table for input_schema if you need full control — Flemma forwards whatever you give it.
The injected harness parameters (flemma.background, flemma.save_to) follow these invariants automatically: on strict tools they are added as nullable and appended to required.
A tool definition can carry a capabilities field — a list of string tags that declare how Flemma should treat the tool. The registry, the approval chain, the harness-parameter injector, and the template processor consult these tags rather than special-casing tool names, so a custom tool opts into the same behaviour as a built-in by declaring the matching tag.
tools.register("my_tool", {
name = "my_tool",
description = "...",
capabilities = { "disables_save_to" },
-- ...
})The vocabulary is closed — each tag gates one specific behaviour:
| Capability | What it gates |
|---|---|
disables_background |
Suppresses injection of the flemma.background harness parameter into the tool's schema. Only meaningful for async tools (sync tools never receive it). The flemma.jobs.status harness tool declares it. |
disables_save_to |
Suppresses injection of the flemma.save_to harness parameter into the tool's schema. The flemma.jobs.status harness tool declares it — harness output is ephemeral coordination metadata that should never be redirected to a file. |
auto_approves_if_sandboxed |
Lets the sandbox approval resolver (priority 25) auto-approve calls to this tool when the sandbox is enabled with an available backend and the user hasn't opted out. Only the built-in bash tool declares it. |
emits_template |
Marks the tool's result content as template-bearing: its parsed inner segments are kept and run through the template engine when the conversation is compiled, so {{ … }} expressions and @./file references inside the result are evaluated. Tools without this tag have their result content sent verbatim. Only the built-in read tool declares it. |
Tags follow a verb_target naming convention (disables_background, auto_approves_if_sandboxed) — a verb describing the behaviour applied to the named target. Custom tools should reuse the tags above; an unrecognized tag is simply never queried and has no effect.
Query a tool's tags programmatically with registry.has_capability(name, capability):
local registry = require("flemma.tools.registry")
registry.has_capability("read", "emits_template") --> true
registry.has_capability("bash", "auto_approves_if_sandboxed") --> trueIt returns false when the tool is unregistered or declares no capabilities list.
Tool definitions that need to call external processes or remote APIs can resolve asynchronously. Flemma gates API requests on all sources being ready – if you send while definitions are still loading, the buffer shows "Waiting for tool definitions to load..." and auto-sends once everything resolves.
Function form – pass a resolve function directly:
tools.register(function(register, done)
vim.fn.jobstart({ "my-cli", "list-tools" }, {
on_exit = function()
register("discovered_tool", { --[[ definition ]] })
done() -- signals this source is complete
end,
})
end)Table form – pass a table with .resolve and an optional .timeout (seconds):
tools.register({
timeout = 60,
resolve = function(register, done)
-- fetch definitions from a remote API...
register("remote_tool", { --[[ definition ]] })
done()
end,
})Module form – export a resolve function from your module:
-- In lua/my_plugin/tools.lua
local M = {}
function M.resolve(register, done)
-- async work...
register("my_tool", { --[[ definition ]] })
done()
end
M.timeout = 45 -- optional, defaults to tools.default_timeout (30s)
return M-- In your setup:
tools.register("my_plugin.tools")Key details:
register(name, def)can be called multiple times within a single source to register several tools.done(err?)must be called exactly once. Pass an error string to signal failure (the source completes but a warning is shown). Double-callingdone()is safe (idempotent).- Timeout – if
done()is never called, the source times out aftertools.default_timeoutseconds (default 30). This prevents a broken source from blocking requests forever. - Error handling – if the resolve function throws,
done(err)is called automatically.
Flemma's built-in MCP integration is implemented as an async tool source -- it's a good reference for the pattern in practice (lua/flemma/tools/definitions/builtin/mcporter.lua).
Flemma uses a priority-based resolver chain to decide whether a tool call should be auto-approved, require user approval, or be denied. The chain evaluates resolvers in priority order (highest first); the first non-nil result wins. If no resolver returns a decision, the default is "require_approval".
Built-in resolvers are registered during setup():
| Priority | Name | Source |
|---|---|---|
| 100 | urn:flemma:approval:config |
Unified resolver — reads the merged tools.auto_approve value from all config layers (DEFAULTS → SETUP → RUNTIME → FRONTMATTER), evaluates a list, function, or $preset reference, and returns a decision. |
| 25 | urn:flemma:approval:sandbox |
Auto-approve tools with auto_approves_if_sandboxed capability when sandbox is enabled and available |
| 0 | urn:flemma:approval:catch-all |
Only when tools.require_approval = false |
Third-party plugins register at the default priority of 50. Set priority higher to run before built-in resolvers (e.g., 200 to override config), or lower to act as a fallback.
Note
Earlier releases used separate config and frontmatter resolvers. The unified resolver at priority 100 now consults the merged config store directly, so frontmatter overrides take effect through layer precedence rather than a separate resolver entry. Per-buffer flemma.opt.tools.auto_approve writes still beat global config — the merge happens before the resolver runs.
The sandbox resolver (priority 25) auto-approves tools that declare "auto_approves_if_sandboxed" in their capabilities array when the sandbox is enabled with an available backend and the user hasn't opted out per-tool or globally. Currently only the built-in bash tool declares this capability. Disable with tools.auto_approve_sandboxed = false in config, exclude specific tools per-buffer with auto_approve:remove("bash") in frontmatter, or take ownership of the policy with auto_approve:set(...) in frontmatter (a set op signals you're handling approval entirely for that buffer). See docs/sandbox.md for the full list of conditions.
local approval = require("flemma.tools.approval")
approval.register("my_plugin.security_policy", {
description = "Block dangerous bash commands",
resolve = function(tool_name, input, context)
if tool_name == "bash" and input.command:match("rm %-rf") then
return "deny"
end
return nil -- pass to next resolver
end,
})The resolve function receives:
tool_name(string) – the name of the tool being called (e.g.,"bash","read").input(table) – the tool call's input arguments.context(table) – containsbufnr(buffer number) andtool_id(unique ID for this tool call).
Return values:
| Return value | Effect |
|---|---|
"approve" |
Auto-approve; skip the approval placeholder step |
"require_approval" |
Show the placeholder and wait for user confirmation |
"deny" |
Block execution; inject an error result |
nil |
Pass; let the next resolver in the chain decide |
If a resolver throws an error, it is logged and skipped (treated as nil).
A module loaded via tools.modules can also register an approval resolver by exporting an approval table alongside its tool definitions. This is the idiomatic way to ship security policy alongside the tools it governs — one entry in the user's config wires up both:
-- In lua/my_plugin/tools.lua
return {
definitions = {
{ name = "deploy", description = "...", input_schema = { ... }, execute = ... },
{ name = "rollback", description = "...", input_schema = { ... }, execute = ... },
},
approval = {
priority = 75, -- optional, defaults to 50
description = "Gate deploy/rollback on git branch",
resolve = function(tool_name, input, context)
if tool_name == "deploy" and vim.fn.system("git branch --show-current"):match("^main") then
return "require_approval"
end
return nil
end,
},
}Wire it up once:
require("flemma").setup({
tools = { modules = { "my_plugin.tools" } },
})The resolver is registered under the module path (e.g. my_plugin.tools), which becomes its name in the resolver chain. Use this pattern when the resolver and the tools it governs ship together; use the direct approval.register() API for plugins that only contribute policy.
approval.unregister("my_plugin.security_policy") -- returns true if foundRe-registering with the same name replaces the existing resolver.
approval.get("urn:flemma:approval:config") -- returns the resolver entry or nil
approval.get_all() -- all resolvers sorted by priority (deep copy)
approval.count() -- number of registered resolvers