Writing plugins
Build a host plugin with @punktfunk/plugin-kit — the manifest, the sandbox, a minimal library source, testing on a host and publishing to a catalog.
A plugin is an npm package that the host's plugin runner loads and supervises. This page takes you
from an empty folder to a package operators can install from the console. Most plugins are library
sources, like punktfunk-plugin-steam;
punktfunk-plugin-rom-manager also has
its own console page.
How a plugin runs
The runner (punktfunk-scripting) starts every package listed in <config>/plugins/package.json
whose name is @<scope>/plugin-* or punktfunk-plugin-*. <config> is ~/.config/punktfunk on
Linux and %ProgramData%\punktfunk on Windows.
- It imports the package's
module(elsemain) entry. The default export must be a plugin definition. - A crash restarts the plugin with backoff, from 1 s up to 60 s. A clean return ends it.
- On stop, the plugin's Effect fiber is interrupted, so scoped finalizers run.
- The plugin calls the management API with a credential of its
own: its own token, or in a Windows AppContainer its own named pipe. It reaches the plugin
allowlist only: it can write its own library titles and registration, but not hooks, pairing,
the plugin store, or a
commandlaunch. - Lines it logs with
Effect.logshow on the console's Troubleshooting page asplugin:<id>.
| Linux | Windows | |
|---|---|---|
| Isolation | One bubblewrap sandbox per plugin, with its own PID namespace | One AppContainer per plugin, punktfunk.plugin.<id>, under the runner's NT AUTHORITY\LocalService account |
| Files | /usr, its own code, its state dir, the manifest's paths and the operator's grants | Program Files, its own code, its state dir, the manifest's paths and the operator's grants; user profiles are closed |
| Network | None unless the manifest sets network | None unless the manifest sets network; never loopback |
| Host API | A socket the runner forwards; connect() finds it | A named pipe per plugin; connect() finds it |
The manifest
Declare what the plugin needs in a punktfunk block in its package.json; the
minimal plugin below has one. It ships inside the reviewed tarball, so
nothing the plugin does at runtime widens it. Without it, a plugin does not start.
| Field | Meaning |
|---|---|
schema | 1. Any other value is refused. |
id | The plugin id: [a-z][a-z0-9-]*, at most 64 characters. Use the same string as the plugin's name; it keys the plugin's token, state dir and library provider. |
reads | Paths the plugin reads. Absolute or ~/-rooted. A missing path is skipped. |
files | Folders the plugin reads the files of, nothing below them. Use it for a folder that holds a game tree: on Windows a reads entry rewrites every file below it, and a files entry lands at once. One * component names each folder under it: %ProgramFiles%/EA Games/*/__Installer is every game's __Installer, new ones within a minute. Needs host 0.46; an older host drops the entry, so also ask for it from wants. |
writes | Paths it also writes. On Linux each one waits for the owner's yes, except one under /tmp: the host asks when the plugin is installed, as a pending folder request, and the folder binds read-write once allowed. On Windows only the operator grants a write. |
network | true to reach the network. |
registry | Windows registry keys it reads, as HKLM\Software\… or HKCU\Software\…: a launcher's own key, never one that holds credentials or policy, nor a parent of one. A key under HKCU, like a path under ~/, marks a source that belongs to one account; the host grants it in every signed-in account's hive. |
exec | Named argv templates the host may run for this plugin's titles. |
The sandbox never binds /, the home directory or its parents, /proc, /sys, /dev, /run
(except /run/media), ~/.ssh, ~/.gnupg or ~/.config/punktfunk. On Linux it never binds a
browser profile, keyring or cloud credential either, nor a parent of one: ~/.mozilla,
~/.config/google-chrome, ~/.var/app/org.mozilla.firefox, ~/.local/share/keyrings, ~/.aws,
~/.kube and the like, and so ~/.config, ~/.local and ~/.var/app whole. On Windows the same
holds for a profile's AppData\Local\Google\Chrome\User Data, AppData\Roaming\Microsoft\Protect,
.aws and their kind. On Linux a writes entry (or a write grant) also never binds what runs at
login, session start or a compositor reload, nor a parent of it: ~/.config/systemd,
~/.config/autostart, ~/.config/hypr, ~/.local/bin, ~/.local/share/applications,
~/.local/share/gnome-shell, and the shell and X start-up files such as ~/.bashrc, ~/.profile
and ~/.xinitrc. Reading those with reads is fine. Any other writes entry outside /tmp
binds once the owner allows the request the host files for it, and stays unbound after a no.
An exec template names a program and its arguments with {param} placeholders. A title supplies
the values, and the host builds the argv itself. exe is a program name looked up on PATH, or an absolute path inside the plugin's roots; it may
be a {param} of kind path. cwd is optional and must also lie inside the roots. Each parameter
declares its kind:
| Kind | Accepts |
|---|---|
name | Printable text, up to 128 characters |
id | [A-Za-z0-9._-], up to 128 characters |
digits | ASCII digits, up to 32 |
path | An absolute path inside the plugin's declared or granted roots |
args | Extra argv elements. The only kind that may start with - or repeat |
No other value may start with -. The host drops a title whose values don't fit when the plugin
publishes it.
A ROM needs no template. launch: { kind: "emulator", value: "pcsx2", args: [...] } names a
catalog emulator, and the arguments platform, file, and optionally core; file must lie
inside the plugin's roots. The host starts file in its best copy of that emulator, after
first-run setup and the platform's firmware. It takes no flags: a title that needs its own goes
through a template.
GET /api/v1/emulators/catalog lists the platforms and emulators. A plugin that moves saves asks
once with saves: true on POST /api/v1/plugin-access/requests.
A minimal library plugin
@punktfunk/plugin-kit/library supplies everything except the scan: the sync loop, file watching,
the console settings form and the CLI. Four files:
{
"name": "@you/plugin-hello",
"version": "0.1.0",
"type": "module",
"module": "./dist/index.js",
"bin": { "punktfunk-plugin-hello": "./dist/cli.js" },
"files": ["dist"],
"scripts": {
"build": "bun build src/index.ts src/cli.ts --target=bun --outdir dist --external effect --external '@punktfunk/*'",
"test": "bun test"
},
"dependencies": {
"@punktfunk/host": "^0.6.0",
"@punktfunk/plugin-kit": "^0.15.0",
"effect": "4.0.1"
},
"punktfunk": {
"schema": 1,
"id": "hello",
"reads": ["~/.local/share/hello"],
"exec": {
"play": { "exe": "hello-launcher", "args": ["{game}"], "params": { "game": "id" } }
}
}
}import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Effect, Schema } from "effect";
import { defineLibraryPlugin } from "@punktfunk/plugin-kit/library";
const dir = () => path.join(os.homedir(), ".local/share/hello");
const db = () => path.join(dir(), "games.json");
export const plugin = defineLibraryPlugin({
name: "hello",
configSchema: Schema.Struct({}),
detect: () => Effect.sync(() => fs.existsSync(db())),
scan: () =>
Effect.sync(() => {
const games: { id: string; title: string }[] = JSON.parse(fs.readFileSync(db(), "utf8"));
return games.map((g) => ({
external_id: g.id,
title: g.title,
launch: { kind: "exec", value: "play", args: [{ name: "game", value: g.id }] },
}));
}),
watchDirs: () => [dir()],
});import { plugin } from "./plugin.js";
export default plugin.def;#!/usr/bin/env bun
import { plugin } from "./plugin.js";
await plugin.cli();Before bun install, add @punktfunk:registry=https://git.unom.io/api/packages/unom/npm/ to
.npmrc.
The host lists each title as hello:<external_id>. Other launch kinds cover the common
launchers; the table is on LaunchSpec in
plugin-kit/src/wire.ts.
Add detect: { install_dir } to a title whose launch hands off and exits, so the host can still
tell when the game quits.
Beyond a library source
| Need | Use |
|---|---|
| Settings | configSchema. The console renders a form from it; the file is <config>/plugin-state/<id>/config.json |
| Your own state | pluginStateDir("<id>"); writable on every platform |
| Data from a desktop app | pluginIngestDir("<id>"), an inbox any local user may write. Treat its contents as untrusted |
| A console page | definePluginKit with serveUi({ title, icon, staticDir, api }) |
| A tab on each game's page | serveUi({ title, game }); see below |
| Art or details for every game | defineMetadataPlugin; see below |
| Work before a game starts | serveUi({ title, holds }); see below |
| Titles that download before they play | serveUi({ title, install }); see below |
| Reacting to events only | definePlugin({ name, main: async (pf) => … }) from @punktfunk/host |
Keep Effect values inside the plugin: the runner bundles its own copy of Effect, so the default
export must be a plain async main. definePluginKit and defineLibraryPlugin build that for you.
A console page runs sandboxed, with an origin of its own: no cookies, localStorage or
indexedDB, and no access to the console or another plugin's page. Its address carries its
credential in the first path segment, so reach your API with relative URLs or through
resolvePluginBase() from @punktfunk/plugin-kit/react, never a hand-written /plugin-ui/<id>/.
Keep state in your plugin's backend, and don't send fetch with credentials: "include".
A tab on each game's page
Something a plugin keeps per game, like the files it swaps or a per-game switch, belongs on that
game's page in the console. Pass game to serveUi:
import { Effect, Schema } from "effect";
import { handedPath, serveUi } from "@punktfunk/plugin-kit";
const Section = Schema.Struct({
enabled: Schema.Boolean.annotate({ title: "Swap this game's files" }),
paths: Schema.Array(handedPath({ write: true })).annotate({ title: "Folders" }),
});
yield* serveUi({
title: "Game slots",
game: {
schema: Section,
load: (entryId) => Effect.succeed(store.get(entryId) ?? { enabled: false, paths: [] }),
save: (entryId, value) => Effect.sync(() => store.set(entryId, value)),
status: (entryId) => Effect.succeed([{ level: "info", text: "No slot active." }]),
},
});entryIdis the library id,steam:570orcustom:<id>.loadreturningundefinedmeans no tab on that entry.savereceives the value decoded againstschema; a body that doesn't decode never reaches it.statusreturns up to eight short lines shown above the form.- A plugin with a
gamesection and nostaticDirgets no nav entry.
A source of art and details
An Art & Metadata source fills covers and details for games other plugins list, the way
punktfunk-plugin-steamgriddb covers every
game. Write how to find a game and what to fetch for it; defineMetadataPlugin does the rest:
import { Effect, Schema } from "effect";
import { defineMetadataPlugin } from "@punktfunk/plugin-kit/metadata";
export const plugin = defineMetadataPlugin({
name: "covers",
configSchema: Schema.Struct({}),
matching: "search",
offers: { art: ["portrait", "hero"], meta: ["developer"] },
match: (entry, cfg, pin) =>
Effect.succeed({ key: pin ?? entry.title, label: entry.title }),
fetch: (match) =>
Effect.succeed({ art: { portrait: `https://covers.example/${match.key}.png` } }),
});
export default plugin.def;- Which games: the kit looks up a game only when it lacks something in
offers, and never a launcher tile. A game that already has the art keeps it unless the operator ticks Use for every game. - Matching:
entry.idscarries the ids its plugin knows (steam,gog,epic,libretro,sgdb).matching: "exact"says you match only by those; exact sources rank before"search"ones by default. - Pins: the operator's Wrong game? stores a
keyfrom yoursearch; it reachesmatchaspin. - Art is an
http(s)URL. The host fetches and keeps it; anything else is dropped. - Failures: fail with
SourceRateLimitedto stop the round and retry later, orSourceUnauthorizedto stop it and show the reason in the console. Any other failure skips that game until the next round. - Choose… on a game's Media tab lists
images(match, kind)from every source, or the one imagefetchfound.lookup <id>on the plugin's CLI prints what it finds for one game.
Add "network": true to the manifest. Register in the store's metadata category so the console
offers it under Library → Sources → Art & Metadata.
Hold a launch
A plugin that must act before a game starts, like swapping its config files, holds the
game.launching stage. The host calls the handler before it spawns the game and waits for it:
yield* serveUi({
title: "Game slots",
holds: {
"game.launching": (game) => swapIn(game.app, game.fingerprint, game.preset),
},
holdTimeoutMs: 60_000,
});gameis the event'sGameRef: app id, store, title, client, fingerprint, plane, profile and the preset the device dialled with.- The stage fires only when the host starts the game, not when it picks up one still running.
holdTimeoutMsis 1–120 000, default 30 000. Past it the host starts the game anyway and logs the plugin; the handler's Effect is interrupted.- A failure logs and never blocks the launch. A plugin mid-restart misses the stage.
- A library source holds through
defineLibraryPlugin({ holds, holdTimeoutMs }); its handler also gets the plugin's config:(game, cfg) => …. It fires for every launch on the host, so check thatgame.appis one of yours.
Install a title
A plugin can list titles that aren't on the host yet: a ROM on a server, a game in a store. Mark
each with install in its entry, serve the host's install requests, and report progress. The host
does the rest: speed and time left, the console's Downloads list, and every client's launch screen.
A player who starts a title that isn't installed waits on that screen until the files are in, then
the game starts.
const reporter = yield* downloadReporter("my-plugin");
yield* serveUi({
title: "My library",
install: {
start: ({ externalId }) => startDownload(externalId, reporter),
pause: ({ externalId }) => pauseDownload(externalId),
cancel: ({ externalId }) => cancelDownload(externalId),
uninstall: ({ externalId }) => removeFiles(externalId),
},
});
// In each entry the plugin reconciles:
// install: { state: "missing", size_bytes: 26e9, target: "/mnt/games/my-plugin" }startanswers once the download is under way, not when it ends. Start a paused one again from where it stopped. Every handler may fail withInstallRefused({ message }): one sentence the player or operator reads ("Not enough space in /mnt/games: it needs 26 GB and 8 GB is free.").reporter.set({ externalId, state, doneBytes, totalBytes, phase, error })on every change. The reporter keeps the host's rules for you: one send a second at most, and a restatement every 5 s while a title isqueued,downloadingorinstalling. A title the host hears nothing about for 30 s counts as stalled, and a launch waiting on it gives up.pausekeeps the partial files,canceldiscards them,uninstallremoves what the plugin downloaded and nothing else. Saves stay.- After a download finishes or files go, reconcile again so the entry's
install.statesays what is on disk.targetmust be a folder the plugin may write; the host shows its free space. - On a host that predates downloads,
reporter.supported()turnsfalse: keep fetching in a launch hold, as before. - A library source passes the four handlers to
defineLibraryPlugin({ install }), each with the config as its second argument, and reports fromdownloads: (reporter, config) => …, which runs beside the sync. The host takes a row for any title the plugin lists, so a source can also report downloads its launcher started.
Folders you can't know in advance
A package can't know where someone keeps their ROMs or a second Steam library. Return those
folders from wants: (cfg) => [...]. Before each scan the kit asks the host for every one the
plugin can't reach, then scans what it can reach now. Return { path, files: true } for a folder
that holds a game tree when its own files are all you read. For a library folder whose game
folders each hold the one file you read, return the pattern { path: "D:\\Games\\*", files: true }
beside the game folders themselves: one yes covers them all, and a host before 0.46 refuses the
pattern and prompts per folder.
A plugin that doesn't use defineLibraryPlugin yields requestAccess(paths, reason) from
@punktfunk/plugin-kit. A request only creates a pending row: the operator allows it on the
library source in the console, or with punktfunk-host plugins grant. A grant restarts the plugin.
Don't tell users to widen the runner unit or change ACLs by hand.
When the operator is the one who knows the folder, a save folder or a config directory, make it a
handedPath() field in game or config. The console grants each folder the operator adds there
when the form saves, read-only unless handedPath({ write: true }). A folder the plugin fills in
itself is never granted that way. On Windows a plugin's own write request is always refused;
write access comes only from the operator, and never inside Program Files or ProgramData.
Test it
Without a host, from your plugin's folder:
bun test
bun src/cli.ts detect # present / absent
bun src/cli.ts scan --preview # the entries a sync would send, as JSONOn a Linux host, install the packed tarball like any plugin. plugins add restarts the runner:
bun run build && bun pm pack --destination /tmp/pf-dev
punktfunk-host plugins add /tmp/pf-dev/you-plugin-hello-0.1.0.tgz --allow-public-registry
journalctl --user -u punktfunk-scripting -f
punktfunk-host plugins remove @you/plugin-hello # when you're doneAny package outside the @punktfunk scope needs --allow-public-registry.
Before a release, guard your title ids. With the previous version installed,
bun src/cli.ts parity --snapshot before.json records what the host lists for your source;
bun src/cli.ts parity --compare before.json diffs your working copy's scan against it and exits
non-zero on any change. A changed id breaks pins and art caches on every client.
Publish
- Publish under a scoped name,
@<scope>/plugin-<id>, to an HTTPS npm registry. The first-party repos publish from CI on av*tag withbun publish. - From the public npm registry, operators can install it by name:
punktfunk-host plugins add @<scope>/plugin-<id> --allow-public-registry. The console shows it as Installed via CLI. - To appear under Browse as Verified, open a pull request against
punktfunk-plugin-index: add an entry tov1/index.jsonpinning the exactversionand the registry'sintegrityhash, then runbun run validate. unom reviews that tarball; each new version is a new pull request. - To run your own catalog, serve an
index.jsonin the same format over HTTPS, with its ed25519 signature at<url>.sig; the index repo'stools/generate a key and sign. Operators add it under Plugins → Sources with your public key. Its plugins install without the Verified badge, and an unsigned source is flagged.