PunktfunkPunktfunk

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 (else main) 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 command launch.
  • Lines it logs with Effect.log show on the console's Troubleshooting page as plugin:<id>.
LinuxWindows
IsolationOne bubblewrap sandbox per plugin, with its own PID namespaceOne 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 grantsProgram Files, its own code, its state dir, the manifest's paths and the operator's grants; user profiles are closed
NetworkNone unless the manifest sets networkNone unless the manifest sets network; never loopback
Host APIA socket the runner forwards; connect() finds itA 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.

FieldMeaning
schema1. Any other value is refused.
idThe 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.
readsPaths the plugin reads. Absolute or ~/-rooted. A missing path is skipped.
filesFolders 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.
writesPaths 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.
networktrue to reach the network.
registryWindows 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.
execNamed 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:

KindAccepts
namePrintable text, up to 128 characters
id[A-Za-z0-9._-], up to 128 characters
digitsASCII digits, up to 32
pathAn absolute path inside the plugin's declared or granted roots
argsExtra 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:

package.json
{
  "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" } }
    }
  }
}
src/plugin.ts
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()],
});
src/index.ts
import { plugin } from "./plugin.js";
export default plugin.def;
src/cli.ts
#!/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

NeedUse
SettingsconfigSchema. The console renders a form from it; the file is <config>/plugin-state/<id>/config.json
Your own statepluginStateDir("<id>"); writable on every platform
Data from a desktop apppluginIngestDir("<id>"), an inbox any local user may write. Treat its contents as untrusted
A console pagedefinePluginKit with serveUi({ title, icon, staticDir, api })
A tab on each game's pageserveUi({ title, game }); see below
Art or details for every gamedefineMetadataPlugin; see below
Work before a game startsserveUi({ title, holds }); see below
Titles that download before they playserveUi({ title, install }); see below
Reacting to events onlydefinePlugin({ 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." }]),
  },
});
  • entryId is the library id, steam:570 or custom:<id>. load returning undefined means no tab on that entry.
  • save receives the value decoded against schema; a body that doesn't decode never reaches it.
  • status returns up to eight short lines shown above the form.
  • A plugin with a game section and no staticDir gets 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.ids carries 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 key from your search; it reaches match as pin.
  • Art is an http(s) URL. The host fetches and keeps it; anything else is dropped.
  • Failures: fail with SourceRateLimited to stop the round and retry later, or SourceUnauthorized to 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 image fetch found. 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,
});
  • game is the event's GameRef: 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.
  • holdTimeoutMs is 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 that game.app is 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" }
  • start answers once the download is under way, not when it ends. Start a paused one again from where it stopped. Every handler may fail with InstallRefused({ 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 is queued, downloading or installing. A title the host hears nothing about for 30 s counts as stalled, and a launch waiting on it gives up.
  • pause keeps the partial files, cancel discards them, uninstall removes what the plugin downloaded and nothing else. Saves stay.
  • After a download finishes or files go, reconcile again so the entry's install.state says what is on disk. target must be a folder the plugin may write; the host shows its free space.
  • On a host that predates downloads, reporter.supported() turns false: 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 from downloads: (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 JSON

On 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 done

Any 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

  1. Publish under a scoped name, @<scope>/plugin-<id>, to an HTTPS npm registry. The first-party repos publish from CI on a v* tag with bun publish.
  2. 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.
  3. To appear under Browse as Verified, open a pull request against punktfunk-plugin-index: add an entry to v1/index.json pinning the exact version and the registry's integrity hash, then run bun run validate. unom reviews that tarball; each new version is a new pull request.
  4. To run your own catalog, serve an index.json in the same format over HTTPS, with its ed25519 signature at <url>.sig; the index repo's tools/ 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.

On this page