Flemma uses registry patterns throughout – tools, providers, approval resolvers, sandbox backends, credential resolvers, and personalities are all pluggable. This guide covers the extension points that don't have a dedicated document, and links to those that do. These registries are the seams into Flemma as a harness: lifecycle hooks (FlemmaJobCompleted, FlemmaToolApprovalRequired, FlemmaAutopilotResuming), programmatic approval, and credential resolution are where external code plugs into the harness's gating and coordination surfaces.
Flemma emits User autocmds at lifecycle boundaries. External plugins listen with standard Neovim autocommand APIs – no Flemma-specific setup required.
| Event name | Autocmd pattern | Payload fields | When it fires |
|---|---|---|---|
request:sending |
FlemmaRequestSending |
bufnr |
Just before an API request is sent |
request:finished |
FlemmaRequestFinished |
bufnr, status ("completed", "cancelled", or "errored"), request? (flemma.session.Request — present on completed status with pricing info) |
After an API request completes (any outcome) |
tool:executing |
FlemmaToolExecuting |
bufnr, tool_name, tool_id |
When a tool invocation starts execution |
tool:completed |
FlemmaToolCompleted |
bufnr, tool_name, tool_id, status ("success" or "error") |
When a tool invocation completes |
tool:approval-required |
FlemmaToolApprovalRequired |
bufnr, tools (array of { tool_id, tool_name, input }) |
When one or more tool calls land on (pending) |
usage:estimated |
FlemmaUsageEstimated |
bufnr |
When a buffer's token estimate cache changes |
config:updated |
FlemmaConfigUpdated |
(none) | After runtime configuration changes (see note below) |
boot:complete |
FlemmaBootComplete |
(none) | After all async tool sources finish loading |
buffer:created |
FlemmaBufferCreated |
bufnr |
After Flemma initializes a .chat buffer |
buffer:destroyed |
FlemmaBufferDestroyed |
bufnr |
When a .chat buffer is wiped or deleted |
sink:created |
FlemmaSinkCreated |
bufnr, name |
When a new output buffer (sink) is created |
sink:destroyed |
FlemmaSinkDestroyed |
bufnr, name |
When an output buffer (sink) is destroyed |
conversation:idle |
FlemmaConversationIdle |
bufnr |
When the conversation reaches idle after a response |
job:submitted |
FlemmaJobSubmitted |
bufnr, job_id, tool_id, tool_name, active_count |
When a tool is moved to background execution |
job:completed |
FlemmaJobCompleted |
bufnr, job_id, tool_id, tool_name, success, active_count |
When a background job result is delivered |
autopilot:resume-scheduled |
FlemmaAutopilotResumeScheduled |
bufnr, delay_ms |
When autopilot schedules a debounced auto-continue |
autopilot:resume-cancelled |
FlemmaAutopilotResumeCancelled |
bufnr |
When a scheduled auto-continue is cancelled |
autopilot:resumed |
FlemmaAutopilotResumed |
bufnr |
When autopilot fires after the resume delay |
Warning
The config:updated event is not yet dispatched consistently across all config mutation paths. Today it only fires from :Flemma switch (provider switching). Other mutations — frontmatter changes, programmatic config.apply() calls — do not emit it yet. Treat it as a best-effort signal for now.
vim.api.nvim_create_autocmd("User", {
pattern = "FlemmaRequestFinished",
callback = function(ev)
if ev.data.status == "completed" then
vim.notify("Request finished for buffer " .. ev.data.bufnr)
end
end,
})Payload fields are available on ev.data. The data table is always present (never nil), even for events with no payload fields like boot:complete. Errors in consumer callbacks are caught and logged – they never crash the request pipeline.
Internal hook names use domain:action format (e.g., request:sending). The autocmd pattern is derived by TitleCasing each segment and prepending Flemma:
request:sending→FlemmaRequestSendingtool:completed→FlemmaToolCompleted- Hyphenated words are split:
tool-use:completed→FlemmaToolUseCompleted
The built-in bufferline integration uses hooks to track busy state:
vim.api.nvim_create_autocmd("User", {
pattern = "FlemmaRequestSending",
callback = function(ev) increment_busy(ev.data.bufnr) end,
})
vim.api.nvim_create_autocmd("User", {
pattern = "FlemmaRequestFinished",
callback = function(ev) decrement_busy(ev.data.bufnr) end,
})In addition to User autocmds, hooks support direct Lua callbacks via hooks.on(). By default hooks are dispatched asynchronously via vim.schedule — both Lua subscribers and the User autocmd fire on the next event-loop tick. Within that tick, Lua subscribers run before the autocmd, in registration order, with per-subscriber error isolation so one buggy listener never blocks another.
local hooks = require("flemma.hooks")
local subscription = hooks.on("job:completed", function(data)
if data.active_count == 0 then
vim.notify("All background jobs finished")
end
end)
-- Later: unsubscribe
subscription:off()hooks.on(name, callback) returns a flemma.hooks.Subscription with an :off() method. Prefer autocmds for external plugins; prefer hooks.on() when you need guaranteed ordering relative to other subscribers or want to avoid the ev.data unwrapping overhead.
If a caller needs subscribers to complete before continuing (for cleanup hooks that must run before state is torn down), hooks.dispatch(name, data, { sync = true }) skips the vim.schedule deferral and invokes subscribers inline. This is used internally for buffer:destroyed. Don't reach for this unless you genuinely need synchronous semantics — the async default keeps subscribers from blocking the main loop.
The tool:approval-required hook pairs with a small public API for resolving approvals outside the buffer. This is how you replace the inline (pending) flow with a confirmation dialog, a statusline picker, or any other UI:
local hooks = require("flemma.hooks")
local tools = require("flemma.tools")
hooks.on("tool:approval-required", function(data)
for _, call in ipairs(data.tools) do
local choice = vim.fn.confirm(
string.format("Approve %s?\n%s", call.tool_name, vim.inspect(call.input)),
"&Approve\n&Reject",
2
)
if choice == 1 then
tools.approve(data.bufnr, call.tool_id)
else
tools.reject(data.bufnr, call.tool_id, "Denied via confirmation dialog")
end
end
end)| Function | Effect |
|---|---|
tools.approve(bufnr, tool_id) |
Sets the tool_result header to (approved). The next phase advance executes it. |
tools.reject(bufnr, tool_id, message?) |
Sets the header to (rejected). Optional message is written into the fence body as the rejection reason. |
Both functions guard against approving/rejecting tools that no longer exist or have already resolved. They nudge autopilot via autopilot.nudge() so an in-flight loop picks up your decision without an extra Ctrl-]. See contrib/extras/approval_dialog.lua for a working example using vim.fn.confirm().
The hook fires once per Phase 1 with all pending tools batched into a single tools array — pick the ones you care about and ignore the rest, or process them all in one prompt.
Flemma resolves API keys and tokens through a priority-based resolver chain. The first resolver that finds a credential wins. Results are cached with TTL awareness to avoid repeated lookups.
| Priority | Resolver | Platform | What it checks |
|---|---|---|---|
| 100 | Environment | All | {SERVICE}_{KIND} env var (e.g., ANTHROPIC_API_KEY), then aliases |
| 50 | Secret Service | Linux | secret-tool lookup service {service} key {kind} (GNOME Keyring) |
| 50 | Keychain | macOS | security find-generic-password -s {service} -a {kind} -w |
| 50 | ChatGPT | All | ~/.codex/auth.json OAuth token (only chatgpt_subscription kind) |
| 25 | Gcloud | All (w/ CLI) | gcloud auth print-access-token (logged in) / application-default print-access-token (via access_token) |
The ChatGPT resolver is not part of the default chain — it is registered only when the experimental Codex provider is loaded (providers.modules = { "flemma.provider.adapters.experimental.codex" }). See docs/providers.md.
- Cache check – if a cached credential exists and its TTL hasn't expired, return it immediately.
- Resolver iteration – try each resolver in priority order (highest first). Each resolver's
supports()method filters by platform, credential kind, or service. - First match wins – the first resolver that returns a value is used. The result is cached.
- Fallback – if no resolver succeeds, a warning notification lists every resolver that was tried and why it couldn't help (e.g., "ANTHROPIC_API_KEY not set", "secret-tool not found on PATH", "gcloud: executable not found"). This diagnostic output helps pinpoint which resolver to configure.
The environment resolver constructs the variable name from the credential's service and kind:
| Service | Kind | Variable checked |
|---|---|---|
| anthropic | api_key | ANTHROPIC_API_KEY |
| openai | api_key | OPENAI_API_KEY |
| vertex | access_token | VERTEX_ACCESS_TOKEN |
| vertex | service_account | VERTEX_SERVICE_ACCOUNT |
| moonshot | api_key | MOONSHOT_API_KEY |
Credentials can also define aliases – alternative variable names checked in order after the convention. For example, vertex/access_token also accepts the legacy VERTEX_AI_ACCESS_TOKEN alias.
Credentials are cached per kind:service key (e.g., api_key:anthropic). The effective TTL is:
effective_ttl = base_ttl * ttl_scale
Where base_ttl comes from the resolver result or credential config, and ttl_scale (default 1.0) allows adjusting the refresh window (e.g., 0.8 to refresh at 80% of token lifetime). When no TTL is set, the credential caches indefinitely until manually invalidated.
Built-in resolvers can be configured through the secrets config namespace. Two resolvers have configurable options today — gcloud and ChatGPT:
require("flemma").setup({
secrets = {
gcloud = {
path = "/usr/local/bin/gcloud", -- override the gcloud binary path (default: "gcloud")
},
chatgpt = {
auth_file = "~/.codex/auth.json", -- override the Codex auth file path
},
},
})The gcloud.path override is useful on NixOS, Guix, or systems where the gcloud CLI is not on $PATH. The chatgpt.auth_file override points the ChatGPT subscription resolver at a non-default Codex auth file; when unset it falls back to $CODEX_HOME/auth.json, then ~/.codex/auth.json. Each resolver reads its own subtree (secrets.gcloud, secrets.chatgpt) via ctx:get_config().
Each resolver owns its config schema (metadata.config_schema), composed into the secrets namespace via DISCOVER — the same pattern provider adapters and sandbox backends use — so a secrets.<name> key only validates once its resolver is registered. secrets.gcloud is always available (a built-in resolver); secrets.chatgpt only validates when the experimental Codex adapter is loaded, since that adapter self-registers the ChatGPT resolver.
Credential resolution runs on the send pipeline, so resolvers must be non-blocking. Implement resolve_async(self, credential, ctx, callback) and drive subprocesses through vim.system(cmd, opts, on_exit) — never vim.fn.system and never vim.system(cmd):wait(). The walker prefers resolve_async when both forms are present.
local s = require("flemma.schema")
local secrets = require("flemma.secrets")
secrets.register("my_vault", {
name = "my_vault",
priority = 60, -- between environment (100) and keyring (50)
-- Optional: own a config schema so `secrets.my_vault.*` is a valid setup() key.
-- Defaults materialize on registration; read the subtree via ctx:get_config().
metadata = {
config_schema = s.object({
mount = s.string("secret"),
address = s.optional(s.string()),
}),
},
supports = function(self, credential)
return credential.service == "my-service"
end,
resolve_async = function(self, credential, ctx, callback)
if vim.fn.executable("vault") ~= 1 then
ctx:diagnostic("vault not found on PATH")
callback(nil)
return
end
vim.system(
{ "vault", "read", "-field=value", "secret/" .. credential.kind },
{ text = true },
vim.schedule_wrap(function(result)
if result.code ~= 0 then
ctx:diagnostic("vault exit " .. result.code .. ": " .. (result.stderr or ""))
callback(nil)
return
end
callback({ value = vim.trim(result.stdout or ""), ttl = 300 })
end)
)
end,
})The resolver contract:
supports(self, credential, ctx)→boolean– whether this resolver can attempt this credential.ctxis aSecretsContext(see below).resolve_async(self, credential, ctx, callback)– the preferred form. Callcallback(result_or_nil)exactly once when done. The walker awaits it viaflemma.readiness, so the send pipeline doesn't block while you fetch a token.resolve(self, credential, ctx)→{ value: string, ttl?: integer } | nil– sync fallback, used only whenresolve_asyncisn't defined. A syncresolvethat does its own blocking I/O (vim.fn.system,:wait(), etc.) freezes the editor — don't do this.metadata.config_schema(optional) – a schema DSL object describing the resolver'ssecrets.<name>config. Defaults materialize when the resolver registers, andctx:get_config()returns the resolved subtree. Mirrors how provider adapters and sandbox backends own their schema; without it,secrets.<name>accepts no keys.
Resolvers receive a SecretsContext that provides:
ctx:get_config()→table|nil– returns the resolver's config subtree fromsecrets.<resolver_name>(e.g.,secrets.gcloudfor the gcloud resolver). Returns a deep copy; modifications don't affect global config.ctx:diagnostic(message)– record a diagnostic explaining why this resolver couldn't help. These are surfaced in the failure notification when all resolvers fail.
For a worked example, see lua/flemma/secrets/resolvers/gcloud.lua — it composes an async sub-resolution (service-account file → token) entirely through vim.system callbacks.
local secrets = require("flemma.secrets")
secrets.invalidate("api_key", "anthropic") -- invalidate a specific credential
secrets.invalidate_all() -- clear the entire cacheProvider adapters are registered the same way as the built-ins. Point providers.modules at one or more Lua module paths and Flemma loads each module, reads its M.metadata, and registers it alongside the built-in adapters:
require("flemma").setup({
providers = {
modules = { "my_plugin.provider.adapters.custom" },
},
})A provider module inherits from flemma.provider.base and exports an M.metadata table. The full contract — the abstract/required/virtual method groups, the constructor metatable chain, the callbacks contract — is documented inline at the top of lua/flemma/provider/base.lua, and docs/providers.md – Registering non-built-in adapters walks through wiring an adapter end to end.
A few metadata fields shape how the request pipeline treats a provider. They are easy to overlook because the built-ins set them implicitly:
| Field | Where | Default | What it does |
|---|---|---|---|
capabilities.output_has_thoughts |
metadata.capabilities |
false |
Whether the provider's reported output_tokens already includes thinking tokens. Affects cost calculation — set true when the API folds reasoning tokens into the output count. |
capabilities.close_on_complete |
metadata.capabilities |
true |
Whether to terminate the HTTP connection once on_response_complete fires. The default suits backends that leave the SSE stream open after the terminal event; set false for providers that must keep the connection alive past completion. |
billing |
metadata |
(unset) | "usage" or "subscription" — the cost model for the provider, surfaced in session accounting. |
endpoint |
instance field (self.endpoint) |
(required) | The base API URL, returned by get_endpoint(). Set it on the instance in new(); override get_endpoint() for dynamic URL construction (e.g. Vertex embeds project/location). |
Missing boolean capabilities default to false at registration time, except close_on_complete, which defaults to true. The other capability flags (supports_reasoning, supports_thinking_budget, outputs_thinking, min_thinking_budget) govern reasoning/thinking normalization and are documented inline in base.lua.
Custom tools can declare capability tags — a capabilities array of string flags on the tool definition. Resolvers, approval policies, and the harness-parameter injector query these tags to decide how to treat the tool, so a tool opts into (or out of) cross-cutting behavior declaratively rather than through bespoke wiring:
tools.register("my_tool", {
name = "my_tool",
description = "...",
capabilities = { "disables_save_to" },
input_schema = s.object({ label = s.string() }),
execute = function(input, ctx) --[[ ... ]] end,
})The tags follow a verb_target convention. The current vocabulary:
| Tag | Effect |
|---|---|
disables_background |
Suppresses injection of the flemma.background harness parameter into the tool's schema (async tools only — sync tools never receive it). |
disables_save_to |
Suppresses injection of the flemma.save_to harness parameter. Use for tools whose output is ephemeral coordination metadata. |
auto_approves_if_sandboxed |
Lets the sandbox approval resolver auto-approve calls when the sandbox is enabled with an available backend. |
emits_template |
Marks the tool's output as containing template expressions (e.g. read emits file content that may include {{ }} syntax). |
Query a tag with require("flemma.tools.registry").has_capability(name, tag). For the fuller treatment — how the harness-parameter injector consults disables_background/disables_save_to (see Harness parameters) and how the priority-25 resolver consults auto_approves_if_sandboxed (see Approval resolvers) — see docs/tools.md. Sandbox auto-approval conditions are detailed in docs/sandbox.md.
A few cross-cutting mechanisms aren't extension points themselves but every non-trivial extension brushes against at least one.
Any code reachable from the send pipeline that needs to wait on subprocess I/O — credential resolution, MCP discovery, tool definition resolution, usage estimation — must use the Suspense/boundary pattern in flemma.readiness. Leaf code raises error(readiness.Suspense.new(message, boundary)); orchestrators catch the sentinel via pcall, subscribe to the boundary, and retry the pipeline when it resolves. Concurrent consumers of the same boundary key (e.g. secrets:vertex:access_token) share one in-flight runner. The async secrets-resolver example above is wired through this — resolve_async doesn't block; the walker keys a boundary on the credential and re-runs the send when the callback fires.
Two practical rules fall out of this:
- Never
vim.system(cmd):wait()from code that can reach the send pipeline. Use the asyncvim.system(cmd, opts, on_exit)form and hang the wait off a readiness boundary. - Any
pcallbetween a leaf that raisesSuspenseand the orchestrator that catches it must checkreadiness.is_suspense(err)and re-raise — otherwise the sentinel is swallowed and the request silently dies.
When two modules genuinely need to call each other and the obvious require would cycle, route through flemma.bridge instead of papering over with delayed require. Bridge exposes lazy accessors that resolve on first use; the source module installs the implementation when it loads.
Anything that inspects conversation structure — roles, tool use/result blocks, thinking blocks, positions — must read from the cached flemma.ast.DocumentNode (via parser.get_parsed_document(bufnr) or, inside tools, ctx:get_parsed_document()). Don't pattern-match buffer lines. If the AST lacks a field you need, extend the AST rather than bypassing it.
Several registries accept a Lua module path string in user config and load it through flemma.loader:
tools.register_module(module_path)— load a tool module from a path string; validates existence up front and defers therequire. The user-facing surface istools.modulesin setup config.sandbox.register_module(module_path)— same shape for sandbox backends.templating.modules— environment populators, loaded the same way.
Use these instead of bare require() when the path comes from config or user input — the loader is Flemma's extensibility contract and gives consistent error messages, lazy semantics, and the path validation users expect.
Preprocessors run after parsing and before the request leaves Flemma — they rewrite conversation segments (file references, includes, ambient context injection) and surface user confirmations. The registry lives at lua/flemma/preprocessor/registry.lua with built-in rewriters under lua/flemma/preprocessor/rewriters/. Confirmation rewriters use the same readiness/Suspense pattern: a rewriter that needs the user's decision raises Suspense on a boundary that resolves when the user replies. Documentation is currently code-only; treat the existing rewriters as the contract until a dedicated doc lands.
These extension points have full documentation in their respective pages:
| Extension point | What it does | Documentation |
|---|---|---|
| Custom tools | Register tools the model can call | docs/tools.md – Registering custom tools |
| Tool capability tags | Declarative flags resolvers and harness injection query (capabilities) |
docs/tools.md – Harness parameters |
| Async tool sources | Resolve tool definitions from external processes/APIs | docs/tools.md – Async tool definitions |
| Approval resolvers | Priority-based chain for tool approval decisions | docs/tools.md – Approval resolvers |
| Custom providers | Register non-built-in API adapters (providers.modules) |
docs/providers.md – Registering non-built-in adapters |
| Sandbox backends | Platform-specific sandbox enforcement | docs/sandbox.md – Custom backends |
| Personalities | Dynamic system prompt generators | docs/personalities.md |
| Template populators | Custom globals for {{ }} and {% %} expressions |
docs/templates.md – Extending the Environment |
| Frontmatter parsers | Custom frontmatter languages (e.g., YAML) | docs/templates.md – Custom frontmatter parsers |
| List op-prefixes | Compose append/prepend/remove/preset spread on list config in any frontmatter | docs/templates.md – Op-prefix syntax for list values |
| Preview formatters | Custom tool preview rendering in pending placeholders | docs/tools.md – Custom preview formatters |