Feather Plugins¶
Feather plugins are Lua modules that extend the functionality of the debugger. Plugins use a server-driven UI approach: they declare their actions and configuration in Lua via getConfig(), and the Feather desktop app renders the UI automatically — no TypeScript code needed.
Creating a plugin¶
To create a plugin, extend the FeatherPlugin base class and implement the lifecycle methods you need:
init(config): Called when the plugin is initialized.update(dt, feather): Called every frame. Wrapped inpcallfor error isolation — a crashing plugin won't take down the game.onerror(msg, feather): Called when a game error occurs.handleRequest(request, feather): Called on each push cycle to get data for the desktop (GET). Return data to be sent, ornilto skip.handleActionRequest(request, feather): Called when a plugin action is triggered from the desktop (POST). Returns(data, err)tuple.handleActionCancel(request, feather): Called when an in-flight action is cancelled from the desktop (DELETE).handleParamsUpdate(request, feather): Called when plugin parameters are updated from the desktop (PUT).finish(feather): Called when the game disconnects.getConfig(): Returns the plugin configuration including server-driven UI actions. Sent to the desktop on connect.
To help with the implementation, Feather provides the FeatherPlugin class, which you can extend to create your plugin.
local FeatherPlugin = require(FEATHER_PATH .. ".core.base")
local MyPlugin = Class({
__includes = FeatherPlugin,
})
function MyPlugin:init(config)
-- config.options contains the options passed to createPlugin
-- config.logger and config.observer are always available
end
function MyPlugin:update(dt, feather)
-- Called every frame (error-isolated via pcall)
end
function MyPlugin:onerror(msg, feather)
-- Called when a game error occurs
end
function MyPlugin:handleRequest(request, feather)
-- Return data to push to desktop each cycle
return {
type = "gallery", -- content type: "gallery", etc.
data = { ... },
loading = false,
}
end
function MyPlugin:handleActionRequest(request, feather)
local action = request.params.action
-- Handle the action, return (data, err) tuple
return "success"
end
function MyPlugin:handleActionCancel(request, feather)
-- Cancel an in-flight action (e.g. stop GIF recording)
end
function MyPlugin:handleParamsUpdate(request, feather)
-- Handle parameter changes from desktop UI
return {}
end
function MyPlugin:isSupported(version)
return version > 0
end
function MyPlugin:finish(feather)
-- Clean up resources
end
function MyPlugin:getConfig()
return {
type = "my-plugin",
color = "#ff0000",
icon = "puzzle", -- Lucide icon name
tabName = "My Plugin", -- Display name in sidebar
actions = {
{ label = "Do Thing", key = "do-thing", icon = "play", type = "button" },
{ label = "Count", key = "count", icon = "hash", type = "input", value = 10, props = { type = "number", min = 1, max = 100 } },
{ label = "Enabled", key = "enabled", icon = "toggle-left", type = "checkbox", value = true },
},
}
end
return MyPlugin
manifest.lua¶
Every plugin that should be auto-discovered by feather.auto needs a manifest.lua at the root of its directory. Without it the directory is silently skipped.
-- plugins/my-plugin/manifest.lua
return {
id = "my-plugin", -- must match the directory name and createPlugin id
name = "My Plugin", -- display name (for tooling / future UI)
description = "What this plugin does",
version = "1.0.0",
capabilities = { "filesystem" }, -- tokens this plugin requires (see Capabilities)
optionalCapabilities = { "draw" }, -- used when granted; the plugin still runs without them
runtime = { cost = "low", update = "active", push = "active", sampleRate = 1 },
actions = { -- optional action contract (see Action contracts)
{ name = "export", cancellable = true },
},
opts = { speed = 1.0 }, -- default options merged with user pluginOptions
optIn = false, -- true = not registered unless in config.include
disabled = true, -- true = registered but inactive by default
}
A manifest is data: one return { ... } table of literals. The catalog generator
(scripts/generate-plugin-catalog.mjs) parses it without running Lua, so function calls, variables
and string concatenation are rejected with the line and column. The generator also fails when an id
does not match its directory, a version is not x.y.z, a capability token is unknown, the runtime
policy is missing or invalid, an action is declared twice, or the plugin has no README.md or Lua
e2e suite. The vocabulary it checks against lives in feather/plugin_contract.lua, which the
runtime reads too.
| Field | Type | Description |
|---|---|---|
id |
string |
Unique plugin identifier. Must match the directory name and the id passed to createPlugin. |
name |
string |
Human-readable display name. |
description |
string |
Short description shown in tooling. |
version |
string |
Semver string. |
capabilities |
string[] |
Capability tokens this plugin needs (e.g. "filesystem", "network"). If the user's config.capabilities does not grant all of them, the plugin never runs. |
optionalCapabilities |
string[] |
Tokens the plugin can use but does not need. Granted ones are passed to init as config.grantedCapabilities. |
runtime |
table |
Required runtime cost policy: cost, update, push, sampleRate. See Runtime Cost Policy. |
actions |
table[] |
Optional action contract: { name, cancellable, description }. When declared, undeclared actions are rejected. See Action contracts. |
opts |
table |
Default plugin options. Merged with (and overridden by) the user's config.pluginOptions[id]. |
optIn |
boolean |
If true, the plugin is not registered at all unless its ID appears in config.include. |
disabled |
boolean |
If true, the plugin registers and appears in the UI but starts inactive. Users can enable it from the desktop, or via config.include. |
Loading and enabling plugins¶
feather.auto scans the bundled or installed plugin directory and reads each manifest.lua.
- Plugins with
optIn = falseanddisabled = falseload automatically unless their ID is inconfig.exclude. - Plugins with
optIn = trueare skipped unless their ID is inconfig.include. - Plugins with
disabled = trueare registered but inactive unless their ID is inconfig.include.
CLI-managed projects use feather.config.lua as the source of truth for plugin selection. Use the CLI helpers to edit that list and keep capabilities in sync:
feather init includes particle-system-playground and shader-graph by default in CLI mode. Development-only plugins such as console and hot-reload remain explicit opt-ins.
Love-event hooks¶
Instead of patching love.* callbacks inside init(), use Feather's callback bus or override the corresponding on* method. FeatherPluginManager patches each love callback once and dispatches through a shared bus — this prevents conflicts when multiple plugins hook the same callback.
Callback bus¶
config.callbacks.register(name, fn, opts) registers a handler on the shared runtime callback bus.
function MyPlugin:init(config)
self.disposeOverlay = config.callbacks.register("draw", function()
-- Runs after love.draw(); use love.graphics here
end)
config.callbacks.register("draw", function()
-- Rare override: run later than default callbacks
end, {
priority = 1000,
})
end
Supported callback names are:
drawkeypressedkeyreleasedmousepressedmousereleasedmousemovedtouchpressedtouchreleasedtouchmovedjoystickpressedjoystickreleasedjoystickhatjoystickaxisgamepadpressedgamepadreleasedgamepadaxis
Ordering rules:
- No priority: FIFO, based on registration order.
- Lower numeric priorities run earlier; higher numeric priorities run later.
- Undefined priorities preserve FIFO order.
- Equal priorities preserve FIFO order.
Use priority as a rare escape hatch. Normal plugins should usually omit it.
Legacy on* methods still work and are routed through the same callback bus. If your plugin only needs Love callback hooks, overriding onDraw, onKeypressed, onMousemoved, and friends remains valid.
function MyPlugin:onDraw()
-- Runs after love.draw(); use love.graphics here
end
function MyPlugin:onKeypressed(key, scancode, isrepeat)
if key == "f5" then self:doSomething() end
end
function MyPlugin:onKeyreleased(key, scancode) end
function MyPlugin:onMousepressed(x, y, button, istouch, presses) end
function MyPlugin:onMousereleased(x, y, button, istouch, presses) end
function MyPlugin:onMousemoved(x, y, dx, dy, istouch) end
function MyPlugin:onTouchpressed(id, x, y, dx, dy, pressure) end
function MyPlugin:onTouchreleased(id, x, y, dx, dy, pressure) end
function MyPlugin:onTouchmoved(id, x, y, dx, dy, pressure) end
function MyPlugin:onJoystickpressed(joystick, button) end
function MyPlugin:onJoystickreleased(joystick, button) end
function MyPlugin:onJoystickhat(joystick, hat, direction) end
function MyPlugin:onJoystickaxis(joystick, axis, value) end
function MyPlugin:onGamepadpressed(joystick, button) end
function MyPlugin:onGamepadreleased(joystick, button) end
function MyPlugin:onGamepadaxis(joystick, axis, value) end
Only override the methods you need — unused ones default to no-ops in the base class.
Replay plugins should also use the callback bus for recording. They may still temporarily wrap polling APIs such as love.keyboard.isDown or love.mouse.getPosition during playback, because replaying polled input is simulation rather than callback observation.
Capabilities¶
Declare the system access your plugin needs in manifest.lua. Before any plugin code runs, FeatherPluginManager checks the required capabilities against the user's config.capabilities allowlist. If any is not granted the plugin is blocked: it stays in the plugin list so the desktop can say why, but its init, update, LÖVE callbacks, payload builder and actions never run, and it cannot be enabled from the desktop. The plugin list carries an inactiveReason with the missing tokens and what to change:
inactiveReason = {
code = "capability-denied",
message = "Requires capabilities this game does not grant: network.",
remediation = "Add \"network\" to capabilities in feather.config.lua (or run `feather config plugins --include network-inspector`), then restart the game.",
missingCapabilities = { "network" },
}
Other codes are invalid-manifest, not-callable, incompatible-api, init-failed, disabled (switched off, can be enabled) and disabled-by-errors.
Capabilities a plugin can use but does not need go in optionalCapabilities; they never block. init(config) receives config.grantedCapabilities, the declared tokens (required and optional) the game grants, so a plugin can skip optional work.
Warning
This is activation control, not a sandbox. Lua plugins run with the game's globals, so a plugin that reaches love.filesystem without declaring "filesystem" is not stopped. Treat third-party plugins as code you are choosing to run; the allowlist protects you from enabling one by accident, not from one written to misbehave.
Available capability tokens:
| Token | Meaning |
|---|---|
"filesystem" |
Reads or writes via love.filesystem |
"network" |
Uses LuaSocket / HTTP |
"audio" |
Accesses love.audio |
"physics" |
Accesses love.physics |
"input" |
Hooks input callbacks (onKeypressed, etc.) |
"draw" |
Hooks onDraw to render overlays |
"binary" |
Sends binary attachments (replay chunks, captures) |
Users can restrict which capabilities are allowed:
require("feather.auto").setup({
-- Only allow filesystem and draw; plugins that require anything else do not run
capabilities = { "filesystem", "draw" },
})
Pass "all" (the default) to skip capability checking entirely.
API Compatibility¶
Feather has a plugin UI/protocol API number. Plugins can declare which desktop API versions they support in manifest.lua; the runtime forwards that metadata to the desktop and disables incompatible plugins instead of letting them fail later.
The current Lua runtime exposes the number as require("feather").API.
-- plugins/my-plugin/manifest.lua
return {
id = "my-plugin",
name = "My Plugin",
version = "1.0.0",
-- Exact API
api = 5,
-- Or a range
-- minApi = 5,
-- maxApi = 6,
}
For manually registered plugins, pass the same metadata through createPlugin:
local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", {}, false, {}, {
minApi = 5,
maxApi = 6,
name = "My Plugin",
version = "1.0.0",
})
Important
Declare an API range when your plugin depends on specific Feather UI nodes, binary attachments, action semantics, or table/gallery formats. Incompatible plugins are shown in the desktop app with an API mismatch message and cannot be enabled.
Runtime Cost Policy¶
Every plugin declares a runtime policy in manifest.lua so Feather can stay cheap while the matching panel is not active:
return {
id = "my-plugin",
runtime = {
cost = "high", -- low | medium | high
update = "active", -- always | active | explicit
push = "active", -- sampled | active | manual
sampleRate = 5,
},
}
active means the plugin runs while its desktop panel is visible. explicit means the plugin should expose isRuntimeActive(feather) and return true only while recording, previewing, replaying, or otherwise doing user-requested work. always is for plugins that do background work the user asked for (input playback, GIF capture); keep that work behind a cheap early return.
A plugin with no runtime policy gets a manifest warning and the cheap default { cost = "low", update = "active", push = "active", sampleRate = 1 }: it costs nothing while its page is closed. Before, an undeclared plugin updated every frame and built a payload every sample. If a plugin you register by hand needs background work, pass its policy:
local manifest = require("plugins.input-replay.manifest")
FeatherPluginManager.createPlugin(InputReplay, "input-replay", opts, false, manifest.capabilities, nil, manifest.runtime)
req:plugins (sent when a plugin page opens) is a forced refresh. It no longer builds every payload inside the command handler: it queues one refresh per running plugin, and the runtime's sample loop serves the queue before regular pushes, under the same per-frame batch (pluginPushBatchSize) and frame budget.
Payload envelope¶
Each pushed payload is wrapped in a versioned envelope. The Inspector treats every field but data as optional, so older runtimes still render:
{
type = "plugin",
plugin = "my-plugin",
data = <the value handleRequest returned>,
schemaVersion = 1, -- plugin UI payload schema (feather/plugin_contract.lua)
pluginVersion = "1.0.0", -- manifest version
generatedAt = 1792170977123, -- wall-clock milliseconds when the payload was built
requestId = "...", -- only on a refresh asked for with a requestId
}
Plugin health¶
The plugin list (feather:hello → plugins[id]) carries one health record per plugin, the same for the Inspector and MCP:
| Field | Meaning |
|---|---|
status |
active, failing (running with recent failures), inactive (disabled or blocked) or disabled-by-errors |
consecutiveFailures, totalFailures |
Failure counts; ten consecutive failures in one phase switch the plugin off |
lastError |
{ phase, message, at }; phases are init, update, suspendedUpdate, callback, payload, action, cancel, params, config |
disabledReason |
Why the runtime switched it off |
lastUpdateMs, lastPushMs, lastPayloadBytes, lastPushAt, pushCount |
Cost of the last update and push |
refreshPending |
A forced refresh is queued |
LÖVE callback errors are now contained like update() errors: they are recorded in health and no longer unwind through love.draw.
Plugin lifecycle¶
The FeatherPluginManager handles the lifecycle of each plugin. Every call into a plugin (update(), LÖVE callbacks, payload builds, actions, params) is wrapped in pcall for error isolation — if a plugin crashes, it won't affect the game or other plugins. After 10 consecutive errors in one phase, a plugin is automatically disabled (inactiveReason.code = "disabled-by-errors"). Use pluginManager:enablePlugin(id) to re-enable it.
A plugin that starts disabled is not constructed until it is enabled. Game code that integrates through DEBUGGER.pluginManager:getPlugin(id).instance still gets an instance (it is constructed on that first access), but the plugin does not update, receive callbacks, push payloads or handle actions until it is enabled. A blocked plugin has no instance: guard with if plugin and plugin.instance then.
Initialization¶
init(config): Called when the plugin is initialized.config.optionscontains the options passed tocreatePlugin,config.logger/config.observerare always available,config.grantedCapabilitieslists the declared capabilities this game grants, andconfig.callbacks.register(...)lets plugins join the shared love-event callback bus.getConfig(): Returns the plugin configuration (type, icon, tab name, actions). Sent to the desktop app on connect.
Data Push (every cycle)¶
handleRequest(request, feather): Called on each push cycle at the configuredsampleRate. Return data to be sent to the desktop, ornilto skip. The return value should includetype(content type),data, and optionallyloadingandpersist.
Action Handling (from desktop UI)¶
handleActionRequest(request, feather): Called when a button action is triggered from the desktop.request.params.actioncontains the action key. Returns a(data, err)tuple — errors are sent back to the desktop as toast notifications. (Before this release a returnederrwas dropped and the desktop saw a success.)handleActionCancel(request, feather): Called when the user cancels an in-flight action from the desktop (e.g. stopping a GIF recording mid-way). For a correlated cancel,request.requestIdandrequest.cancelledActionname the request being cancelled.
Action contracts and request correlation¶
A manifest may declare its actions. When it does, an action not in the list is refused before the plugin sees it, and only cancellable actions accept a correlated cancel:
Any cmd:plugin:action may carry a string requestId (letters, digits, -_.:, at most 128 characters). Every plugin:action:response produced because of it carries the same requestId at top level. An action that finishes later calls request.defer() and keeps the job:
function MyPlugin:handleActionRequest(request)
if request.params.action == "export" then
self.exportJob = request.defer() -- acknowledged now with data.pending = true
return { started = true }
end
end
-- later, from update():
self.exportJob:progress({ fraction = 0.5 }) -- status = "progress", final = false
self.exportJob:complete({ download = ... }) -- status = "success", final = true
-- or self.exportJob:fail("message") -- status = "error", final = true
-- self.exportJob:isCancelled()
cmd:plugin:action:cancel names the request it cancels with its own requestId. Only that request is cancelled; the reply has status = "cancelled" and the same id. A cancel for a request that is not in flight is answered with an error and never reaches the plugin, so a stale cancel cannot stop newer work. A cancel without a requestId behaves as before and leaves correlated requests alone. Disabling a plugin ends its in-flight requests with an error.
- handleParamsUpdate(request, feather): Called when the user changes an input or checkbox value in the desktop UI. request.params contains the updated key-value pairs.
Update¶
update(dt, feather): Called every frame, error-isolated viapcall. Use for ongoing work like batch encoding, timers, or frame capture.
Error Handling¶
onerror(msg, feather): Called when a game-level error occurs (not plugin errors). Use to capture state or clean up.
Finish¶
finish(feather): Called when the game disconnects or shuts down.
Registering a plugin¶
To register a plugin, you need to create an instance of it and pass it to the FeatherPluginManager. The FeatherPluginManager will handle the lifecycle of the plugin and call the appropriate functions.
local MyPlugin = require("my-plugin")
local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", {
-- Plugin options
})
Plugin options¶
The plugin options are passed to the plugin's constructor. Here's an example of a plugin with options:
local MyPlugin = require("my-plugin")
local plugin = FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", {
option1 = "value1",
option2 = "value2",
})
Feather Options¶
By default, every plugin has the following properties available:
self.logger: A logger that logs messages to the Feather logger.self.observer: A logger that logs messages to the Feather observer.
Plugin configuration¶
Plugins return configuration via getConfig() that drives the desktop UI. This uses a server-driven UI pattern — the Lua side declares what controls are needed, and the desktop renders them as native components.
Config fields¶
| Field | Type | Description |
|---|---|---|
type |
string |
Unique plugin type identifier. |
color |
string |
Hex color for the plugin's sidebar icon. |
icon |
string |
Lucide icon name. |
tabName |
string |
Display name shown in the sidebar navigation. |
actions |
table |
Array of action definitions (see Action types). |
Action types¶
Actions define the interactive controls rendered in the plugin page. Each action requires label, key, icon, and type. Some types support an optional value (default/initial value) and props (extra configuration).
| Field | Type | Required | Description |
|---|---|---|---|
label |
string |
Yes | Display text for the control. |
key |
string |
Yes | Unique identifier. Sent as the action/param key. |
icon |
string |
Yes | Lucide icon name. |
type |
string |
Yes | One of: button, input, checkbox, select, vector, file. |
value |
any |
No | Default value (string, number, or boolean). |
props |
table |
No | Extra configuration — varies by type (see below). |
group |
string |
No | Group name for card layout (see Grouped card layout). |
type = "button"¶
Renders a button. Clicking sends cmd:plugin:action with params.action set to the action key. The plugin's handleActionRequest receives it.
type = "input"¶
Renders a text input field. Value changes send cmd:plugin:params. The plugin's handleParamsUpdate receives the key-value pair.
{ label = "FPS", key = "fps", icon = "gauge", type = "input", value = 30,
props = { type = "number", min = 5, max = 60, step = 1 } }
Supported props:
| Prop | Type | Description |
|---|---|---|
type |
string |
HTML input type ("number", "text", etc.). Use "number" to restrict to numeric entry. |
min |
number |
Minimum value (for type = "number"). |
max |
number |
Maximum value (for type = "number"). |
step |
number |
Step increment (for type = "number"). |
placeholder |
string |
Placeholder text. |
disabled |
boolean |
Disable the input. |
readOnly |
boolean |
Make the input read-only. |
type = "checkbox"¶
Renders a checkbox. Toggle sends cmd:plugin:params with "true" or "false" as the value.
type = "select"¶
Renders a dropdown select menu. Value changes send cmd:plugin:params.
{ label = "Mode", key = "mode", icon = "settings", type = "select", value = "normal",
props = { options = { "normal", "additive", "multiply" } } }
Supported props:
| Prop | Type | Description |
|---|---|---|
options |
table |
Array of string values to choose from. |
type = "vector"¶
Renders a multi-field input for comma-separated values (e.g. coordinates, colors). Each value gets its own labeled input field. Value changes send the joined comma-separated string via cmd:plugin:params.
-- 3 labeled fields: Start, Mid, End
{ label = "Sizes", key = "sizes", icon = "ruler", type = "vector",
value = "1.0, 0.5, 0.0",
props = { labels = { "Start", "Mid", "End" }, type = "number", min = 0, max = 10, step = 0.01 } }
-- Repeating labels cycle through the array (e.g. RGBA groups)
{ label = "Colors", key = "colors", icon = "palette", type = "vector",
value = "1, 0.5, 0, 1, 0, 0.2, 0.8, 0",
props = { labels = { "R", "G", "B", "A" }, repeating = true, type = "number", min = 0, max = 1, step = 0.01 } }
Supported props:
| Prop | Type | Description |
|---|---|---|
labels |
table |
Array of labels for each field (e.g. {"X", "Y"}, {"R", "G", "B", "A"}). |
repeating |
boolean |
When true, labels cycle through the array. Groups values into rows of #labels size (e.g. RGBA → rows of 4). |
type |
string |
HTML input type for each field (default: "number"). |
min |
number |
Minimum value per field. |
max |
number |
Maximum value per field. |
step |
number |
Step increment per field. |
type = "file"¶
Renders a button that opens a native file picker dialog. The selected file's contents are sent to handleActionRequest.
{ label = "Import", key = "import", icon = "upload", type = "file",
props = { filters = { { name = "Lua files", extensions = { "lua" } } } } }
Supported props:
| Prop | Type | Description |
|---|---|---|
filters |
table |
Array of file filters: { name = "Label", extensions = { "ext" } }. |
Grouped card layout¶
By default, actions render in a horizontal toolbar at the top of the plugin page. For plugins with many controls, you can organize actions into named groups using the group field. Grouped actions render in a responsive card grid below the toolbar.
actions = {
-- These appear in the toolbar (no group)
{ label = "Export", key = "export", icon = "download", type = "button" },
{ label = "Reset", key = "reset", icon = "rotate-ccw", type = "button" },
-- These appear in a "Speed" card
{ label = "Min Speed", key = "speedMin", icon = "gauge", type = "input", value = 100,
props = { type = "number", min = 0, max = 1000 }, group = "Speed" },
{ label = "Max Speed", key = "speedMax", icon = "gauge", type = "input", value = 500,
props = { type = "number", min = 0, max = 1000 }, group = "Speed" },
-- These appear in a "Visual" card
{ label = "Sizes", key = "sizes", icon = "ruler", type = "vector", value = "1, 0.5, 0",
props = { labels = { "Start", "Mid", "End" }, type = "number" }, group = "Visual" },
{ label = "Colors", key = "colors", icon = "palette", type = "vector",
value = "1, 0, 0, 1",
props = { labels = { "R", "G", "B", "A" }, repeating = true, type = "number" }, group = "Visual" },
}
Layout behavior:
- Actions without
grouprender in the toolbar (top row, horizontal). - Actions with
grouprender in a responsive card grid below the toolbar. - Cards are ordered by the first appearance of each group name.
- The grid is responsive: 1 column on small screens, up to 4 on wide screens.
- All action types (
button,input,checkbox,select,vector,file) work inside cards. Buttons and selects expand to full width within a card.
Action response types¶
When a plugin's handleActionRequest returns data, the desktop app handles it based on the response shape:
Error response¶
Return an error string as the second value of the (data, err) tuple. The error is shown as a toast notification.
Download response¶
Return a download table to trigger a native save-file dialog on the desktop.
function MyPlugin:handleActionRequest(request, feather)
return {
download = {
filename = "export.lua", -- suggested filename
content = "-- exported", -- file content (string)
extension = "lua", -- file extension for the filter
},
}
end
Clipboard response¶
Return a clipboard string to copy text to the user's clipboard with a success toast.
function MyPlugin:handleActionRequest(request, feather)
return {
clipboard = "local ps = love.graphics.newParticleSystem(image, 100)",
}
end
Content types¶
The handleRequest method returns data that the desktop renders below the actions. The type field determines the layout:
| Type | Description |
|---|---|
ui |
Declarative Feather UI tree rendered by the desktop. |
gallery |
Image grid with download buttons. Supports PNG screenshots and GIF animations. |
table |
Data table with sortable columns. |
tree |
Collapsible tree view with properties on each node. |
timeline |
Chronological event timeline with categories and optional screenshots. |
ui¶
Lua plugins can describe UI declaratively with feather.ui primitives. React is only the renderer; plugins should send stable Feather UI nodes and action keys, not raw React components or JavaScript.
For the full schema, supported nodes, and complete examples, see Plugin UI.
function MyPlugin:handleRequest(request, feather)
return feather.ui.render(
feather.ui.panel({
title = "Player Stats",
feather.ui.row({
feather.ui.badge({ value = "HP" }),
feather.ui.text({ value = tostring(player.health) }),
}),
feather.ui.button({
label = "Kill Player",
action = "kill-player",
}),
})
)
end
function MyPlugin:handleActionRequest(request)
if request.params.action == "kill-player" then
player.health = 0
end
end
Supported node types in the first schema pass: panel, row, column, tabs, tab, text, button, input, textarea, checkbox, switch, select, badge, stat, progress, alert, list, link, separator, image, code, table, timeline, and inspector.
Buttons use action = "some-key" (or onClick = "some-key") and are routed through the existing handleActionRequest() path. Function callbacks are intentionally not serialized.
gallery¶
return {
type = "gallery",
data = {
{ type = "image", name = "shot1", downloadable = true, metadata = { type = "png", src = base64data, width = 800, height = 600 } },
},
loading = false,
persist = true, -- merge with previous data instead of replacing
}
For large images or binary payloads, prefer Feather's hybrid protocol instead of embedding base64 in JSON. Call feather:attachBinary(mime, bytes) and place the returned src and binary fields in your normal plugin data. Feather sends the JSON first, then streams the bytes on a binary WebSocket frame; the desktop swaps feather-binary:<id> into a local blob URL automatically.
function MyPlugin:handleRequest(request, feather)
local pngBytes = self:getLatestPreviewPng()
local preview = feather:attachBinary("image/png", pngBytes)
return {
type = "gallery",
data = {
{
type = "image",
name = "preview.png",
downloadable = true,
metadata = {
type = "png",
src = preview.src,
binary = preview.binary,
width = 800,
height = 600,
},
},
},
loading = false,
}
end
For GIF-style frame lists, put the placeholder URLs in src and the matching binary refs in binary using the same order:
metadata = {
type = "gif",
src = { frame1.src, frame2.src },
binary = { frame1.binary, frame2.binary },
width = 800,
height = 600,
fps = 30,
}
The same works in table rows. The desktop resolves src to a blob URL and renders it as a download/open control when that column is visible; the binary field can stay hidden metadata if you do not add a matching column.
function MyPlugin:handleRequest(request, feather)
local file = feather:attachBinary("application/octet-stream", bytes)
return {
type = "table",
columns = {
{ key = "name", label = "Name" },
{ key = "size", label = "Size" },
{ key = "src", label = "File" },
},
data = {
{ name = "dump.bin", size = tostring(#bytes), src = file.src, binary = file.binary },
},
loading = false,
}
end
table¶
return {
type = "table",
columns = {
{ key = "name", label = "Name" },
{ key = "value", label = "Value" },
},
data = {
{ name = "FPS", value = "60" },
{ name = "Memory", value = "12.5 MB" },
},
loading = false,
}
tree¶
return {
type = "tree",
nodes = {
{
name = "Player",
properties = { { key = "health", value = "100" }, { key = "x", value = "320" } },
children = {
{ name = "Inventory", properties = { { key = "items", value = "5" } } },
},
},
},
sources = { "ECS", "Scene" }, -- data source names for the source selector
selectedSource = 0, -- currently selected source index
searchFilter = "", -- current search filter text
loading = false,
}
timeline¶
return {
type = "timeline",
items = {
{ id = 1, label = "Game started", category = "lifecycle", time = 0, gameTime = "0:00" },
{ id = 2, label = "Level loaded", category = "lifecycle", color = "#4ade80", time = 1.5, gameTime = "0:01" },
},
categories = { "lifecycle", "error" },
loading = false,
}
Adding plugins to feather.auto¶
feather.auto registers all built-in plugins for you. You can extend it with your own plugins through the plugins key in setup(), without giving up the zero-config defaults.
Append a custom plugin¶
Pass additional plugins via config.plugins. They are appended after all built-in plugins:
local FeatherPluginManager = require("feather.plugin_manager")
local MyPlugin = require("my-plugin")
require("feather.auto").setup({
plugins = {
FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", {
option1 = "value1",
}),
},
})
DEBUGGER is created as usual, and your plugin appears in the sidebar alongside the built-in ones.
Start a custom plugin disabled¶
Pass true as the fourth argument to createPlugin. The plugin is registered and visible in the desktop UI, but starts inactive — users can enable it from the desktop without restarting.
Force-enable a built-in plugin¶
Built-in plugins that are optIn (like console) or start disabled are not active by default. Add their IDs to config.include to force-enable them:
include both registers optIn plugins (which would otherwise be skipped entirely) and activates disabled ones.
Exclude a built-in plugin¶
Remove a built-in plugin entirely with config.exclude. It won't be registered at all:
Override built-in plugin options¶
Pass per-plugin option overrides via config.pluginOptions, keyed by plugin ID. Options are merged over the built-in defaults:
require("feather.auto").setup({
pluginOptions = {
screenshots = { fps = 60, gifDuration = 10 },
["memory-snapshot"] = { autoInterval = 5 },
},
})
Full example¶
local FeatherPluginManager = require("feather.plugin_manager")
local MyPlugin = require("my-plugin")
local AnotherPlugin = require("another-plugin")
require("feather.auto").setup({
sessionName = "My Game",
-- Force-enable built-in opt-in plugins
include = { "console" },
-- Remove plugins you don't need
exclude = { "hump.signal", "lua-state-machine" },
-- Override options for specific built-in plugins
pluginOptions = {
screenshots = { fps = 60 },
},
-- Append your own plugins
plugins = {
FeatherPluginManager.createPlugin(MyPlugin, "my-plugin", { debug = true }),
FeatherPluginManager.createPlugin(AnotherPlugin, "another-plugin", {}, true), -- starts disabled
},
})
Using Plugin Actions¶
Plugins can be triggered from game code at runtime using debugger:action():
local debugger = FeatherDebugger({
debug = true,
plugins = {
FeatherPluginManager.createPlugin(ScreenshotPlugin, "screenshots", {
screenshotDirectory = "screenshots",
fps = 30,
gifDuration = 5,
}),
},
})
function love.keypressed(key)
if key == "f1" then
debugger:action("screenshots", "screenshot", {})
elseif key == "f2" then
debugger:action("screenshots", "gif", { duration = 3, fps = 30 })
end
end
Actions can also be triggered from the desktop UI — button actions in getConfig() send cmd:plugin:action messages over WebSocket, and the response (success/error) is shown as a toast notification in the desktop app.
Plugin examples¶
Here are some examples of Feather plugins:
- Hump's Signal Plugin
- Lua State Machine Plugin
- Screenshot Plugin
- Particle Editor Plugin
- Audio Debug Plugin
- Coroutine Monitor Plugin
- Collision Debug Plugin
- Animation Inspector Plugin
- Feel Inspector Plugin
- Timer Inspector Plugin
Plugin documentation¶
Each plugin should have a README file that explains how to use it and provides examples.