Plugins
A plugin adds things a flexdeck deck can do (actions), live values it can show
and react to (variables), and things that happen that an automation can start
on (events). OBS Studio, for example, adds actions like switch scene, variables
like obs.recording, and events like scene_changed.
There are two kinds of plugin, and nothing above the registry can tell them apart:
| Built in | Installed | |
|---|---|---|
| Lives in | crates/flexdeck-plugin/src/builtin/ |
<data>/plugins/<id>/<version>-<sha>/ |
| Runs as | Rust, inside the agent | its own process (Node or a native binary) |
| Examples | core, system, media, launcher, notifications |
obs |
| Added by | a release | the marketplace (a repository), a .fdpkg file, or a linked folder |
Using plugins
From the window (desktop app). The Marketplace lists every plugin from every
repository this computer reads, as tiles: icon, name, author, version, when it last
changed, where it comes from, and what it may do. Search and the Official ·
Community · Custom · Updates chips filter the list. Other ways to install takes a
.fdpkg file or links a folder you are writing. Each installed plugin has
Settings, Log, Restart, Turn off and Remove.
From the portal. The Plugins tab shows the same marketplace and repositories. Both come from the desktop, and installing goes through the desktop: it downloads the package itself and checks it against the checksum the repository's index pins.
Repositories
Plugins come from repositories: a repository.min.json file anyone can host,
listing each plugin's latest version and where its package is. There are three kinds:
| Kind | What it is | Can be |
|---|---|---|
| Official | flexdeck's own repository, Questory-Labs/FlexDeck-Plugins | always on |
| Community | third-party repositories flexdeck has reviewed, from a list published next to the official one | turned off |
| Custom | any repository you add by address | removed |
Add one under Repositories → Add a repository by address. example.com,
example.com/plugins and a full …/repository.min.json address all work; a bare
host or folder means its repository.min.json. The desktop reads it first and shows
its name and plugins before anything is saved. A custom repository is marked as not
reviewed, here and again when you install from it.
Only https:// is accepted, except to this computer (http://localhost,
http://127.0.0.1). The desktop re-reads repositories every six hours, or when you
press Refresh, and keeps the last copy of each, so the marketplace still lists
what it knew while offline. A plugin is only offered updates from the repository it
was installed from.
Nothing installs without its permissions being shown and accepted. The agent
enforces this, not just the UI: an install whose permissions were not all accepted
is refused with 409, and the refusal lists what to ask for.
| Permission | Means |
|---|---|
network |
connect to other programs and the internet |
notifications |
show desktop notifications |
processes |
start other programs |
filesystem |
read and write files outside its own folder |
Plugins are programs. An installed plugin runs as a process with your account's rights; permissions are a declaration you consent to, not a sandbox. What the agent does enforce is everything that passes through it: a plugin can only publish variables under its own
<id>.namespace, only emit events it declared, only show notifications if it asked, never claim a permission its package did not declare, and every call it is asked to make has a deadline.
Binding a button to a live value
Any button can follow a plugin's variables (portal → select the widget → Looks):
- Light up while a bool is true —
obs.recordingmakes a red record light. - Label shows a template —
REC {{obs.record_time}},{{obs.scene}},Vol {{system.audio.volume}}%. - Icon follows a text variable holding a bundled icon's name or a picture a
plugin handed over (
img:<sha256>) —{{media.current.art}}shows album art. - Colours set the key's face and text; a lit key keeps the deck's glow.
The built-in time plugin makes a clock out of a label: {{time.hhmm}},
{{time.h12mm}} {{time.ampm}}, {{time.weekday}}, {{time.date}}. Its values
are in the desktop's time zone.
The tablet updates the moment the value changes. Values are only kept fresh while a tablet is connected, so an idle agent polls nothing.
Automations
Two triggers come from plugins:
- A plugin announces — e.g. OBS: the live scene changed, optionally only
when
sceneisLive. - On a schedule — every N minutes, or at a time of day on chosen days.
Writing a plugin
pnpm create-plugin weather "Weather" # copies plugins/template
pnpm install
pnpm --filter @flexdeck/plugin-weather test
pnpm --filter @flexdeck/plugin-weather run package # -> weather-0.1.0.fdpkg
plugins/template/src/index.ts uses every part of the contract once, with a note
on each. The whole plugin is one definePlugin call:
import { definePlugin } from "@flexdeck/plugin-sdk";
export default definePlugin({
id: "weather",
name: "Weather",
version: "0.1.0",
permissions: ["network"],
settings: { city: { kind: "text", default: "London" } },
variables: { "weather.temperature": { kind: "number", unit: "°C" } },
events: { rain_started: { fields: ["city"] } },
actions: {
refresh: {
description: "Fetch the weather now",
run: async (_args, ctx) => {
const temperature = await fetchTemperature(ctx.settings.city);
ctx.vars.set({ "weather.temperature": temperature });
},
},
},
// Asked only for variables something is watching.
sample: async (wanted, ctx) => ({ "weather.temperature": await fetchTemperature(ctx.settings.city) }),
start: (ctx) => ctx.log.info("ready"),
});
What ctx gives an action: ctx.settings (with defaults), ctx.vars.set/declare/get,
ctx.emit(event, payload), ctx.notify(title, body) and ctx.log.info/warn/error.
console.log is redirected to the plugin's log, so it can never corrupt the
protocol.
The authoring loop
Link the folder (window → Get plugins → Writing a plugin?, or start the agent
with FLEXDECK_DEV_PLUGINS=<dir>). The agent runs it straight from the folder and
restarts it on every save; build with pnpm run build (or a watch) and the deck
picks up the change in about a second.
Testing without the agent
@flexdeck/plugin-sdk/testing drives a plugin exactly as the agent does:
import { checkDefinition, harness } from "@flexdeck/plugin-sdk/testing";
await checkDefinition(plugin, new URL("../plugin.json", import.meta.url)); // what the agent will accept
const host = await harness(plugin, { settings: { city: "Paris" } });
await host.invoke("refresh");
assert.equal(typeof host.variable("weather.temperature"), "number");
The OBS plugin's tests (plugins/obs/test/) run the whole plugin against an
in-memory OBS, with no network — a good model for a plugin that talks to another
program.
Packaging
A .fdpkg is a zip with plugin.json at its root:
{
"abi": 3,
"id": "weather",
"name": "Weather",
"version": "0.1.0",
"runtime": "node",
"entry": "dist/index.js",
"permissions": ["network"]
}
flexdeck-plugin pack bundles src/index.ts (SDK included, so no node_modules
ships), checks that the code and plugin.json agree on id, version and
permissions, and writes a byte-for-byte reproducible zip — the same files always
give the same SHA-256. For a plugin written in another language, use
"runtime": "native" with "entries": {"windows": "bin/weather.exe", ...} and
speak the protocol below.
Host your own repository
A repository is a static folder: repository.min.json, the packages, and any icons.
GitHub Pages, a raw GitHub URL, or any web server will do.
npm run package # -> weather-0.1.0.fdpkg
mkdir -p site/packages && cp *.fdpkg site/packages/
npx flexdeck-plugin repo site/packages --out site --name "My plugins" --icons site/icons
flexdeck-plugin repo reads each package's plugin.json, keeps the highest version
of each plugin, and writes repository.min.json (and a readable
repository.json). Addresses in it are relative to the index, so the folder can
move. Run it again after adding a package: releaseDate stays the day a plugin
first appeared, latestUpdateDate moves only when its version does, and a version
that comes back with different bytes is refused. A published version never
changes; publish a new one instead. docs/examples/plugin-repo.yml
is a GitHub Actions workflow that does all of this on every push and publishes to
GitHub Pages.
The format (schema 1):
{
"schema": 1,
"repo": { "name": "My plugins", "description": "…", "website": "https://…" },
"plugins": [{
"id": "weather", "name": "Weather", "version": "0.1.0", "abi": 3,
"description": "…", "author": "…", "homepage": "https://…",
"permissions": ["network"],
"releaseDate": "2026-03-01T00:00:00Z",
"latestUpdateDate": "2026-09-20T00:00:00Z",
"icon": "icons/weather.png",
"icons": { "64": "icons/weather-64.png", "256": "icons/weather-256.png" },
"package": { "url": "packages/weather-0.1.0.fdpkg", "sha256": "…", "size": 12345 }
}],
"iconPacks": [{
"id": "my-icons", "name": "My icons", "version": "1.0.0",
"description": "…", "author": "…", "count": 120,
"package": { "url": "packs/my-icons.zip", "sha256": "…", "size": 54321 }
}]
}
iconPacks is optional. Pass --icon-packs <dir> to flexdeck-plugin repo to list
every <id>.zip in that folder; each zip needs a manifest.json (Name, Author,
Version), at its root or in an <id>/ folder, and an icons.json beside it gives
the count. The icon picker offers the packs from every enabled repository and
refuses a zip whose SHA-256 differs from its entry. A bad or repeated pack entry is
hidden; it never hides the plugins.
| Field | Notes |
|---|---|
package.sha256 |
The SHA-256 of the .fdpkg. The desktop refuses a package whose bytes differ. |
package.url, icon, icons |
Absolute, or relative to the index. |
icon, icons |
PNG, JPEG or WebP, under 128 KB. SVG is not shown. |
releaseDate, latestUpdateDate |
RFC 3339. |
The package's own plugin.json must agree with its entry on id and version. An
entry that cannot be read is hidden, and the rest of the repository still lists;
the same id twice refuses the whole repository. Fields the desktop does not know
are ignored, so a repository can carry more than this.
Getting listed as a community repository means asking for your repository's
address to be added to repositories.min.json in the official repository. The
checksum proves a package is the one your index lists, not that the index is
honest, so a review looks at who runs the repository as well as at the plugins.
Verify before you share
npm run verify (TypeScript) or flexdeck-plugin verify (Python) packs the plugin and asks the FlexDeck on this computer whether it will work: flexdeck verify-plugin <folder | .fdpkg>. The plugin runs as an install would run it, with the same host, deadlines and rules, and each step is checked:
- the package and catalogue are ones the agent accepts;
- every gallery key could be saved in a deck;
- every argument has an example of its kind, and the plugin accepts every action's examples;
- it answers when its values are wanted;
- its feeds start and stop for a key and leave nothing behind;
- it carries on after a settings change;
- it stops when asked.
Throughout, anything the host refuses fails the step it happened in: a value outside its namespace, an event it didn't declare, a notification without the permission, more messages than its budget, or text on stdout that isn't the protocol. --run-actions also runs every action with its examples; it's off by default because actions do real things. A recipe is checked the same way: its sources and presets as the agent reads them, the permission it needs, and no secret with a value in the package. Because the SDKs only find FlexDeck and show its answer, the rules can't drift between the app and the SDKs. Set FLEXDECK_BIN if FlexDeck isn't installed in the usual place.
Python plugins
pip install flexdeck, then flexdeck-plugin new <id> "<Name>". The SDK uses only the standard library. A packed plugin ("runtime": "python", ABI 4) carries the SDK beside its own files, so it needs only Python 3.10+ on the computer. The agent runs it with -u -E -s: unbuffered, with no PYTHON* variables and no user site-packages. Set FLEXDECK_PYTHON to choose the interpreter.
from flexdeck_plugin import Plugin, arg
plugin = Plugin(id="lights", name="Desk lights", version="0.1.0", permissions=["network"])
plugin.variable("lights.on", "bool", "Whether the lights are on")
@plugin.action("toggle", "Turn the lights on or off", {"room": arg("text", "desk")})
def toggle(args, ctx):
ctx.vars.set({"lights.on": not ctx.vars.get("lights.on")})
if __name__ == "__main__":
plugin.serve()
flexdeck_plugin.testing has Harness (the host's side, in process) and check_definition.
What one plugin can't do to another
Everything a plugin sends passes through the host, which enforces these rules:
- Values: it may only publish or declare values under its own
<id>., including the per-key<id>.@<key>.values of its feeds, which are dropped when the key goes. - Events and notifications: it may only emit events it declared, and only show notifications with the permission.
- Settings: it may only store settings it declares, and it's only ever given its own settings and secrets.
- Asking the host: it can't ask the host for anything. There's no request to read another plugin's values, settings or files.
- Environment: it starts with none of the agent's environment except what any program needs (
PATH, its temp and home folders, proxy settings), so nothing the agent was started with reaches it. - Data folder: it gets a data folder of its own.
These are tested in crates/flexdeck-plugin-host/tests/host.rs. The limits are what a process is: a node, native or Python plugin runs as you, so the operating system lets it read what you can, including FlexDeck's data folder. Recipes run no code, so they have no such reach.
The protocol
An installed plugin is a child process speaking JSON-RPC 2.0, one message per line,
on stdin/stdout. Stderr is its log. The contract is crates/flexdeck-plugin-abi;
the SDK's types mirror it, and the two are pinned to each other by the fixtures in
contract/fixtures/plugin/ (the SDK writes them, the Rust tests parse them with
unknown fields refused).
The agent calls:
| Method | Params → result |
|---|---|
initialize |
{abi, settings, dataDir} → {manifest, variables} — always first |
check |
{action, args} → {ok, error?} — refuse a value before anything runs |
invoke |
{action, args} → {ok, error?, state?} — state (ABI 3) names the look a widget should show |
choices |
{action, args} → {arguments: {name: [{value, label?}]}, error?} — optional; the editor's suggestions |
sample |
{wanted: [names]} → {values: {name: {kind, value}}} |
settings/changed |
{settings} → {} |
shutdown |
{} → {}, then exit |
The plugin may send at any time: vars/set {values}, vars/declare {variables, replace}, event/emit {event, payload}, log {level, message}, notify {title, body}
— and, under ABI 3:
| Notification | What it does |
|---|---|
settings/store {values} |
Keep values for settings the manifest declares — a refresh token just issued. Only declared keys; not echoed back as settings/changed. |
host/open_url {url} |
Open an http(s) address in the person's browser, for a sign-in page. Needs the network permission. |
images/put {sha256, mime, data} |
Hand over a picture (PNG, JPEG or WebP, base64, at most 256 KiB, checked against its hash). A widget shows it when an icon template names img:<sha256>. |
ABI versions
A package names its ABI in plugin.json, and the agent speaks that one: it
accepts 2, 3 and 4. Everything ABI 3 added is something a plugin may send —
slider metadata on an action (slider: {argument, min, max, step, feedback?}),
state in an invoke answer, and the three notifications above — so an ABI 2
plugin keeps working unchanged. The TypeScript SDK answers in whichever the agent
asks for, leaving newer fields out under older ABIs. A repository entry for an ABI this
agent does not speak is shown as needing a newer flexdeck and is never offered
as an update.
ABI 4: capabilities, feeds, presets, series
From ABI 4, new features arrive as capabilities rather than new ABIs. The
host lists what it offers in initialize (hostCapabilities), the plugin lists
what it uses in its manifest (uses), and something only one side names is
never used — so either side can be older. ABI 4 brings three, and later agents add more:
| Capability | What it adds |
|---|---|
series |
series/set {name, points} — a variable's whole recent history (at most 600 points, oldest first) for a graph: a day of prices, a forecast's hours. |
presets |
Manifest presets — ready-made keys for the editor's gallery: {id, name, category, description, w, h, widget}, where widget is a widget in the configuration's JSON (its face, flows, states). |
feeds |
Manifest feeds and feed/watch {instance, feed, args} / feed/unwatch {instance} — values per key, with that key's own arguments. Publish them as <id>.@<instance>.<field>; the key reads them as {{this.<field>}}. |
toggles |
An action's active may name a fixed value (is) rather than an argument, and its variable may hold {{argument}} placeholders: a toggle lit while discord.voice.mute is true. |
setup |
A setting may be internal (kept by the plugin, cleared by signing out) or carry help, and the manifest may link a setup guide. |
conditions |
An action's active may be a condition alone: active: { when: "obs.studio_mode and obs.preview_scene != obs.scene" }. It is an expression over any live values, at most 2000 characters. An {{argument}} placeholder stands for the argument's value, as part of a name or as a whole operand, never inside quotes. A name with -, / or spaces is written in backticks: `my-plugin.on`. The SDKs leave the light out, and log a line, when the agent does not offer it. |
In TypeScript, presets: [...] and feeds: {...} go in definePlugin, and the
context gains ctx.series.set(name, points) and ctx.feed(instance).set({...}),
which declares and publishes a key's values. The test harness has watch,
unwatch, feed(instance) and series(name). In Rust, Sidecar has
watch_feed and unwatch_feed, and Host has set_series.
examples/weather-node in the plugins
repository uses all three and is the reference.
Every notification is rate-limited per plugin (vars/set 50 a second, log 20,
images/put 2, notify one every two seconds, …); what is over is dropped,
with one line in the plugin's log.
Recipes: plugins without code
A recipe is a package with "runtime": "recipe" and a recipe.json instead
of a program. It holds web data sources and gallery presets, and the
agent runs it itself — no process, no Node. A recipe may only read the web:
it may not run a program, it must declare the network permission (asked for on
install, like any plugin's), and its sources are named after it (weather,
weather-hourly). Removing it removes its sources.
{
"sources": [{
"id": "crypto",
"everySeconds": 120,
"args": [{ "name": "coin", "default": "bitcoin" }],
"http": { "url": "https://api.coingecko.com/api/v3/simple/price?ids={{args.coin}}&vs_currencies=usd" },
"fields": [{ "name": "price", "value": "json.{{args.coin}}.usd" }]
}],
"presets": [{
"id": "ticker", "name": "Ticker", "w": 2, "h": 1,
"widget": { "sources": [{ "source": "crypto" }],
"face": { "elements": [{ "template": "{{ this.price | currency }}" }] } }
}]
}
recipes/ holds four that ship: weather, crypto, calendar (any iCal address)
and Home Assistant. Pack one with flexdeck-plugin pack <folder>; the agent's
tests load every one as an install would.
Data sources
The same sources can be made in the editor, under Data, without a package:
a URL or a program, how often, settings a key can change ({{args.city}}), and
fields — each an expression over what came back (json.current.temperature_2m,
text, status, now), becoming src.<source>.<field>. Test it now shows
the answer; clicking a value in it adds it as a field. An iCal answer reads as
json.events (upcoming, sorted). Each source also publishes .ok, .error
and .updated; a key reading a failing one shows an amber dot.
Fetches are fenced: http(s) only, 1 MB, 10 s, at most three redirects, and every
address checked — on the first hop and each redirect — so a source reaches this
computer or your network only when it says lan ("May reach my network").
A token or API key is a secret setting: { "name": "token", "secret": true }.
Its value never goes into the deck's config, so tablets, undo history and
anything shared never carry it. The agent keeps it on this computer, puts it into
the request when the source runs, and blots it out of error messages and test
answers. The first token any key gives also serves the source's other keys until
they have their own. A field may not read a secret (args.token in a field is
refused).
Sharing a page
A page can be saved as a .fddeck file (page settings → Share). The file is JSON holding the page, the pictures it shows, and the Data-tab sources its keys read. It never holds a token or a key: the deck's config doesn't contain them, and a source's secret settings are left blank. Opening one shows what it would do before anything is added:
- in red, anything that runs a program, opens an app or a page, presses keys or types text, runs a command source, or reaches your network;
- then the plugins it uses that this desktop doesn't have.
Keys get fresh ids. A source whose id you already use is renamed, and the keys that read it are updated to match.
Faces, templates and rules
A key's look is a face: a layout (icon + label, value, picture, split, dial),
elements in its places (text, icon, bar, gauge, graph, clock), a background
(colour, fade, picture, a graph behind) and rules that recolour it while a
value holds (system.cpu.usage > 80). Templates are expressions with filters:
{{ system.cpu.usage | round }}% {{ btc.price | currency("EUR") }}
{{ 20.4 | temp }} (°C or °F, the desktop's) {{ media.current.position | duration }}
{{ x | map(0: "sun", 3: "cloud", _: "fog") }} {{ if(obs.recording, "REC", "idle") }}
Filters: round, number, pct, currency, temp, abs, floor, ceil,
sign, clamp, min, max, upper, lower, trim, truncate, default,
map, time, duration, bytes. A template that does not parse is refused on
save with the reason. Numbers, money, times and temperatures follow the desktop's
Formats (System tab). The same templates work in an action's text arguments:
"Temp: {{vars.temp}}".
An element can sit anywhere instead of in its layout's place. "box": {"x": 0.1, "y": 0.2, "w": 0.5, "h": 0.3} gives fractions of the face, which is the key above any strip of zones. Its slot still decides its text's size and alignment. In the editor, drag any piece on the Arrange canvas, or set its Place to Anywhere.
A long title can scroll instead of being cut short: "text": {"marquee": true}.
Timers are built in. The timer actions are start, toggle, pause, resume, reset and add, each naming a timer (focus, tea). A timer's values are timer.<name>.running, .left, .ends, .length and .done. When its time is up it announces timer/finished with its name, which a flow or an automation can start on. A ELEMENT_KIND_TIMER element with "variable": "focus" shows the countdown. The tablet counts it down itself from the seconds the agent last sent, so no two clocks need to agree. The Pomodoro and Tea timer presets use one.
A face may have zones: up to five buttons in a strip along the key's bottom,
each with its own flows. The rest of the face sits above the strip. A flow with
"zone": "next" runs when that button is tapped; a press above the strip runs
the key's own flows. A zone's icon can follow a value (when + iconOn), such
as play turning into pause. The Now playing + controls preset is a 3×1 example.
"face": { "zones": [
{ "name": "prev", "icon": "skip-back" },
{ "name": "play", "icon": "play", "when": "media.current.playing", "iconOn": "pause" },
{ "name": "next", "icon": "skip-forward" }
]},
"flows": [{ "trigger": { "gesture": "GESTURE_SHORT_PRESS" }, "zone": "next", "blocks": [ … ] }]
Sliders
An action with slider metadata can be driven by a slider widget: the slider's
value is written into argument, at most once per 50 ms while it is dragged
(the last value always runs), and feedback — a variable name with
{{argument}} placeholders filled from the action's other arguments — is what
the thumb follows when the value changes elsewhere:
set_volume: {
arguments: { input: { kind: "text", example: "Mic" }, percent: { kind: "number", example: "50" } },
slider: { argument: "percent", feedback: "obs.input.{{input}}.volume" },
run: …,
}
Native plugins
A package can be a native executable ("runtime": "native", with entries per
platform). crates/flexdeck-plugin-sdk speaks the protocol for one written in
Rust — implement Sidecar, call serve — and the
soundboard is one.
Host::put_image(bytes, mime) hands the agent a picture and returns the
img:<sha256> to put in a text variable, as ctx.images.put does in TypeScript.
Deadlines: 10 s for initialize and invoke, 3 s for check, sample and
settings/changed. A plugin that misses one is restarted. A plugin that crashes is
restarted after 1, 2, 4 … 30 seconds; one that crashes more than five times in a
minute is stopped, with that reason shown, until someone presses Restart.
Where things live
| Path | What |
|---|---|
crates/flexdeck-plugin-abi |
The contract: manifest, messages, plugin.json, ABI version |
crates/flexdeck-plugin |
The registry and the built-in plugins |
crates/flexdeck-plugin-host |
Runs an installed plugin: process, protocol, deadlines, restarts |
crates/flexdeck-server/src/plugins.rs |
Install, update, uninstall, enable, link, reload |
crates/flexdeck-server/src/widgets.rs |
What each widget does and shows: flows, looks, live labels and icons, sliders |
crates/flexdeck-server/src/images.rs |
Pictures plugins hand over, served at /api/images/<sha256> |
crates/flexdeck-plugin-sdk |
The Rust SDK, for native plugins |
crates/flexdeck-server/src/repos.rs |
Repositories: reading, caching, the marketplace list, updates |
crates/flexdeck-plugin-abi/src/repo.rs |
The repository format |
packages/plugin-sdk |
The TypeScript SDK, test harness, packer and repository builder |
plugins/template |
The copy-me plugin |
recipes/ |
Recipes the agent's tests load; published from the plugins repository |
The official plugins and recipes (OBS Studio, Streamer.bot, Discord, Spotify, the
soundboard, weather, calendar, crypto, Home Assistant) live in their own
repository, Questory-Labs/FlexDeck-Plugins. It is built with the
published SDKs, and its repository.min.json is the official repository the
app reads.