Writing Plugins
A Fern plugin is a directory inside the plugins folder that contains a
manifest.json and a main.lua entry point. When Fern starts (and plugins
are enabled) it reads each manifest.json and then executes each main.lua
in lexicographic order. main.lua should register whatever commands,
keybindings, events, or renderers the plugin provides. It is not called again
after startup.
~/.config/fern/plugins/
my-plugin/
manifest.json
main.lua
manifest.json
Every plugin should include a manifest.json at the root of its directory/repo. All
fields are required. This file is used to help display pertinent details to users in Fern's UI.
| Field | Type | Description |
|---|---|---|
name |
string | Unique plugin identifier (lowercase, hyphens allowed) |
version |
string | Semantic version string, e.g. "1.0.0" |
description |
string | Short description of what the plugin does |
maintainer |
string | Author name and email, e.g. "Jane Doe <jane@example.com>" |
repo |
string | URL of the plugin's source repository |
{
"name": "my-plugin",
"version": "1.0.0",
"description": "Does something useful in Fern.",
"maintainer": "Jane Doe <jane@example.com>",
"repo": "https://codeberg.org/jane/fern-my-plugin"
}
Plugins installed from the Fern-Terranium registry do not require a
manifest.json in the tarball, as the registry already serves that metadata.
For manually installed plugins, Fern requires a valid manifest.json exist
with all fields present in order to load the plugin. So it's considered best
practice to include that file in your project to ensure manual installations
work without issues.
The fern global
All API functions are exposed on the fern global table. They are available
from the moment main.lua begins executing.
Registration
These functions are called during startup (inside main.lua) to register
behaviour.
fern.add_command(name, fn)
Registers a command that users can invoke from the command bar (:).
fern.add_command("reverse", function()
local content = fern.get_content()
if content then
fern.set_content(content:reverse())
end
end)
Conflict handling: Built-in command names (such as w, q, wq, note,
Plugin, etc.) are reserved by Fern and cannot be overridden by plugins. If a
plugin attempts to register a name that is already taken either by a built-in,
or by another plugin that was loaded earlier, the registration is silently dropped.
In this case, a startup warning is shown in the command bar. For now, the first
registered handler will always wins. We will be actively working on a more elegant
handling of conflicts for a future release.
fern.map(mode, key, fn)
Registers a keybinding for the given editor mode.
| Mode | When active |
|---|---|
"normal" |
Normal / navigation mode |
"insert" |
Insert mode |
"visual" |
Visual selection mode |
fern.map("normal", "ctrl+t", function()
fern.notify("current vault: " .. fern.vault())
end)
Scope and conflict handling: App-level keys such as ctrl+q (quit),
ctrl+s (save), palette key, command bar key, search key, help key, sidebar
key, and all tab-management keys, are consumed by Fern before any plugin sees
them and cannot be remapped by plugins. If two plugins register the same mode
and key, the first one loaded wins and the second receives a startup warning.
We will be actively working on a more elegant handling of conflicts for a
future release.
fern.on(event, fn)
Registers a handler that fires when the named event occurs.
| Event | When fired |
|---|---|
"open" |
A file is opened in the editor |
"save" |
The active file is saved |
"close" |
A file tab is closed |
fern.on("save", function()
fern.notify("saved!")
end)
fern.set_timer(seconds, fn)
Registers a function that fires repeatedly at the given interval (minimum 1 second).
fern.set_timer(60, function()
fern.os_notify("Fern", "60 seconds have passed")
end)
fern.register_block_renderer(fence, fn)
Registers a custom renderer for fenced code blocks with the given fence name. The function receives the block content as a string and must return a Lua array of strings with one entry per display line.
fern.register_block_renderer("shout", function(content)
local lines = {}
for line in content:gmatch("[^\n]+") do
lines[#lines + 1] = line:upper()
end
return lines
end)
A fenced block in a note with ```shout will be rendered using this
function instead of the default code block style.
Content
fern.get_content()
Returns the full text of the currently open file as a string, or nil if no
file is open.
fern.set_content(text)
Replaces the content of the currently open file with text. Takes effect
after the current Lua call returns.
fern.notify(message)
Displays message in the command bar status area.
fern.list_files(kind)
Returns an array of stem names for all files of the given kind in the vault.
kind |
Files returned |
|---|---|
"note" |
Notes |
"journal" |
Journal entries |
"template" |
Templates |
fern.read_file(stem, kind)
Returns the text content of the file with the given stem and kind, or nil
if no matching file is found.
Utilities
fern.now()
Returns a table describing the current local time.
| Field | Type | Description |
|---|---|---|
year |
number | Four-digit year |
month |
number | Month (1-12) |
day |
number | Day of month (1-31) |
hour |
number | Hour (0-23) |
min |
number | Minute (0-59) |
sec |
number | Second (0-59) |
iso |
string | ISO 8601 datetime string, e.g. "2026-10-15T14:30:00" |
fern.store_get(key)
Returns the string value stored under key for this plugin, or nil if the
key has not been set. Each plugin has its own isolated key namespace backed by
the vault's fern.db.
fern.store_set(key, value)
Stores value (a string) under key for this plugin.
fern.shell(command)
Runs command in a shell and returns its stdout as a string (trailing newline
stripped). Returns an empty string if the command fails.
local branch = fern.shell("git -C " .. fern.vault() .. " rev-parse --abbrev-ref HEAD")
fern.os_notify(title, body)
Sends a native desktop notification. Uses osascript on macOS and
notify-send on Linux. Runs asynchronously and does not block Fern.
Vault queries
fern.stems_for_tag(tag)
Returns an array of note stems that carry the given frontmatter tag.
local reading = fern.stems_for_tag("reading-list")
fern.frontmatter(stem, kind)
Returns a table of scalar frontmatter fields for the given stem, or nil if
the file does not exist or has no frontmatter. kind defaults to "note".
local fm = fern.frontmatter("my-note")
if fm then
fern.notify("title: " .. (fm.title or "untitled"))
end
fern.search(query)
Runs a full-text search against the vault and returns an array of result tables.
| Field | Description |
|---|---|
stem |
Stem of the matching file |
kind |
"note", "journal", or "template" |
snippet |
Short excerpt showing the match |
I/O and navigation
fern.read_file_path(path)
Reads an arbitrary file from the filesystem by absolute path. Returns its
content as a string, or nil on error.
fern.json_decode(str)
Parses a JSON string and returns the equivalent Lua value (tables for objects
and arrays, numbers, strings, booleans, or nil). Returns nil if the string
is not valid JSON.
fern.vault()
Returns the absolute path to the current vault directory.
fern.open(stem, kind)
Opens the file with the given stem and kind in the editor.
fern.open("inbox", "note")
fern.pick(items, fn)
Opens Fern's picker UI populated with items and calls fn with the value
the user selects. Each item may be either a plain string or a table with
label and value fields.
local notes = fern.list_files("note")
fern.pick(notes, function(selected)
fern.open(selected, "note")
end)
Example plugin
An example plugin can be found here: https://codeberg.org/bugwhisperer/fern-word-count/
Additionally, the Fern-Terranium registry lists all reviewed and approved Fern plugins: https://codeberg.org/InodeLabs/fern-terranium/