pastilhas 16f340262a say what this plugin is for, in the store's words
tags are how the marketplace is browsed rather than scrolled: a list of them on
the left, the plugins that carry one on the right. a closed vocabulary
(PLUGIN_TAGS in the platform) because free text becomes homelab, home-lab, Home
Lab and selfhosted inside a month, and a filter list of synonyms is worse than
no filter at all.

declared here rather than curated in the catalogue, so a plugin can say what it
is and nobody edits a table when one ships. the marketplace caches it on sync,
exactly as it already does for label and summary.
2026-08-27 06:49:31 +00:00
2026-08-16 04:09:04 +00:00

Example

The reference Officer plugin. One API route, two panels, a sidecar that does nothing but stay alive — deliberately the smallest thing that is still a real plugin, so that every part of it is there to be copied rather than read around.

It is not a fixture. It clones from the marketplace, installs, mounts, appears on the dock and answers GET /api/example/ping like anything else. The only part it leaves out is db/, because tables are the one piece that is easier to add than to strip back out.

If you are about to write a plugin, read this file and then read the directory. The comments in manifest.ts, api/router.ts, web/panels.ts and web/layout.ts say why each convention is what it is; this file says what the conventions are.

What it ships

Part Present What it demonstrates
manifest.ts yes the required file — identity, presentation, permissions
api/router.ts yes one route, mounted at /api/example
web/ yes two panels and a layout, arranged into a Workspace by the shell
sidecar/index.ts yes a PM2 process named officer-example
db/schema.ts no omitted on purpose — see above
assets/icon.png no omitted, so the dock falls back to the manifest's lucide glyph

The anatomy of a plugin

A plugin is a directory at plugins/<app-name>/ inside the platform repository. Discovery is by convention and presence is the declaration: there is no field anywhere saying "this plugin has a sidecar", because the file either exists or it does not.

plugins/example/
├── manifest.ts          REQUIRED. Must export `manifest`. Everything a directory listing cannot say.
├── api/router.ts        There is a backend. Must export `router`, a Hono router. Mounted at the prefix.
├── db/schema.ts         There are tables. Pushed on install. Every name prefixed `example_`.
├── sidecar/index.ts     There is a process. Run under bun, as PM2's `officer-example`.
├── sidecar/index.mjs    The same, run under node. The extension IS the runtime; `.ts` wins if both exist.
├── web/                 There is a frontend. Its presence REQUIRES the two files below.
│   ├── panels.ts        REQUIRED with web/. Exports `appRegistryMetas` — at least one panel.
│   ├── layout.ts        REQUIRED with web/. Exports `defaultLayout` — how those panels are arranged.
│   └── *.tsx            The panel components themselves. No naming rule; nothing reads these directly.
└── assets/icon.png      The dock tile's artwork. Copied to `public/plugins/example/` on install.

Everything except manifest.ts is optional. A directory whose name starts with . or _ is not a plugin at all, which is what makes _scratch/ a usable scratch space next to real ones.

Four things follow from that tree and are worth stating plainly, because each is a rule you can only break silently:

  • appName is the directory name. It is not declared anywhere. It is the route segment, the table prefix, the PM2 process suffix and the install key, all at once — taken from the directory so the id cannot disagree with where the code sits. It must match ^[a-z][a-z0-9-]{0,38}$: it has to work as a URL segment, a SQL identifier prefix and a directory name simultaneously. Renaming the directory re-identifies the plugin.
  • web/ is all or nothing. A web/ directory missing either panels.ts or layout.ts makes the plugin broken rather than partially built. This is how "every plugin route renders a Workspace with at least one panel" is enforced by shape instead of by review — the plugin never renders a screen, it hands over panels and an arrangement, and the shell renders WorkspaceView around them. There is nowhere to put anything else.
  • The sidecar's runtime is its file extension. sidecar/index.ts runs under bun, sidecar/index.mjs under node. Implicit, but it is the rule the platform already follows for its own processes, and it beats a manifest field that can contradict the file it describes.
  • A broken plugin is reported, not thrown. One unreadable manifest does not stop the platform booting or hide the plugins beside it. It shows up on the plugins page with the reason.

Where it mounts

publisher is the only input to the prefix:

Publisher API Frontend route
officerdev /api/example /example
anything else /api/p/<pub>/example /p/<pub>/example

Third-party plugins live under /p/<publisher>/ so that no plugin can shadow a core route — which is what lets the platform keep adding core routes forever without breaking an install. First-party ones sit at the root because Officer Dev owns that namespace anyway. Nothing else in the platform branches on provenance; if it did, first-party and third-party would be two systems and only one of them would get tested.

The frontend gets a route pair: <prefix> and <prefix>/:section, exactly as the core screens do. Panels read useParams themselves, so which section is open lives in the URL rather than in state passed between them. web/ExampleDetail.tsx is there to show that and nothing else — try /example/anything.

Writing your own

  1. Clone this repository and rename the directory. The new name is your appName; everything else derives from it.

    git clone https://gitea.officer.dev/plugins/example.git ~/officer/platform/plugins/weather
    cd ~/officer/platform/plugins/weather && rm -rf .git && git init
    

    It has to live inside the platform repository. That is load-bearing rather than tidy: Bun resolves hooks/useClient, officerdev, officerdb and the @@/* alias through the platform's workspace links, and resolution walks up from the importing file. A plugin cloned beside the platform can import nothing the platform provides.

  2. Edit manifest.ts. New label, summary, color, and a permission key that starts with your app name. Leave publisher as officerdev unless you have your own.

  3. Rename what the manifest does not cover. The PM2 process name comes from the directory, but the string inside sidecar/index.ts, the panel keys in web/panels.ts, the appTypes and ids in web/layout.ts, and the component names are all yours to change. Every appType in layout.ts must be a key from panels.ts — the screen pins the allowed types to your plugin's own panels, so a mismatch silently falls back to the first one instead of rendering what you meant.

  4. Delete what you do not need. No frontend? Remove web/ entirely — but do not remove only one of panels.ts or layout.ts. No process? Remove sidecar/. Both are absences, not settings.

  5. Add db/schema.ts if you need tables, prefixing every table name with <appName>_.

  6. bunx tsgo, then install it from /plugins.

The manifest

import type { PluginManifest } from '@@/plugins/manifest';

export const manifest: PluginManifest = { … };
Field Required What it does
publisher yes Who published it. The only input to the mount prefix. Lowercase letters, digits, dashes.
version yes The plugin's own semver. Updates compare against this.
platform yes Which platform versions this build is good for. Refused at install when it does not match.
label yes The dock tile's name, and how the plugin is titled in the marketplace.
summary yes One line, shown in the marketplace before anyone installs it.
color yes Accent colour: tile background, active glow, indicator dot, tint on the plugins list. Required even when the tile draws no background, because the other three still use it.
icon no A lucide icon name. The fallback, used only when the plugin ships no assets/icon.png.
tile no badge (a coloured square with the icon inset) or bare (artwork fills the tile). Defaulted from whether assets/icon.png exists — artwork gets bare, a glyph gets badge. Only set it to override that.
permissions yes See below. [] is valid and means the plugin gates nothing.
osDependencies no Host binaries the plugin shells out to. Absent means self-sufficient, which example is.

There is deliberately no sidecar, schema, frontend, dock or title field. Those are all either declared by a file's presence or already derivable — the dock tile is exactly { label, icon, color, route: prefix }. Writing them twice could only ever drift.

A manifest is validated on discovery and reports every problem at once rather than the first, because a manifest fixed one field per attempt is a manifest nobody finishes.

osDependencies

osDependencies: [
  {
    binary: 'ffprobe',
    reason: 'Reads tags and duration. Without it every track indexes as a bare filename.',
    packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
  },
];

The binary is probed on PATH, not the package — so a manager where the tool is built in simply has no entry, and a present binary means the package names are never looked at. Package names are per manager rather than canonical-with-overrides because the alternative was tried and hides real differences. reason is required: it is what the owner reads when deciding whether to let this machine install something as root, and a package name alone does not answer that question.

These two strings are the only values in a manifest that reach a root command line, so they are validated more narrowly than any package manager would accept — no +, no ~, no :, no version pins. A plugin that needs one of those wants a conversation, not a wider regex.

Missing dependencies are checked first on install, before anything on the machine changes, and refuse the install when they cannot be satisfied. The failure being guarded against is not a crash: it is a plugin that runs, answers 200 and quietly produces nothing.

Permissions

permissions: [
  {
    key: 'example',
    label: 'Example',
    description: 'The reference plugin',
  },
];
  • The key shares the platform's permission namespace, so prefix it with your app name. A plugin declaring chat or terminal would be redefining a core permission — and widening or narrowing one is a security change made by accident. Keys colliding with a core one are refused at mount and logged; the plugin is left with no permission of that name rather than with somebody else's. One key per surface, prefixed, is the only way to be sure you are not taking something.
  • The first permission gates the plugin's whole surface — it claims the mount prefix, on the API and in the router. Later entries are registered as grantable keys that claim no path, which is useful for something the plugin checks itself and misleading if you expected them to gate sub-paths. They do not, yet.
  • Renaming a key is a data change. It is stored as the subject of every grant.
  • There is no ownerOnly, and no kind of any sort. Every plugin permission is registered as kind app: grantable per role, at read or write, on the same page as the platform's own. A plugin cannot name a restricted kind because its manifest has no field for one — the escalation question is removed rather than answered. A plugin that wanted to be owner-only would be a plugin the owner cannot delegate, and "which members may reach this" belongs to whoever runs the server, not to whoever wrote the code.
  • readOnlyWrites lists paths that look like writes and are not — a POST /ssh-test that probes, a POST /assist that proposes and never saves. Without declaring them, an account holding the permission at read meets what reads as a broken feature where a withheld permission should be.
  • Finer visibility is the plugin's own business. Whose rows a member sees, and what "read" means for your data, lives in your queries. The platform's answer is uniform: read, write, or nothing.

What a plugin may and may not do

May:

  • Mount a Hono router under its prefix, built with createRouter() so the platform's context types and middleware behave exactly as they do for core routes — ctx.get('user') is typed and populated.
  • Import anything the platform's workspaces expose, by package name: hooks, officerdev, officerdb, helpers, types, and the @@/* alias for server code. This is the whole plugin-to-host API. There is no publishing step and no version negotiation.
  • Own tables in the platform's Postgres database, created by bun db:push on install.
  • Run a long-lived process, supervised by PM2 as officer-<appName>.
  • Contribute panels to its own screen, and a tile to the dock.
  • Cause host packages to be installed, through osDependencies and only with the owner's consent.

May not:

  • Render a screen. Panels and a layout, or no frontend.
  • Mount outside its prefix. The prefix is computed from the manifest; a router cannot choose it.
  • Choose a permission kind, or take a core permission key. Always app, always grantable, never a core key.
  • Be installed by anyone but the owner. /api/plugins refuses every non-owner: installing a plugin mounts routes and starts a process, which is process control rather than a feature to grant a read of. The permission that gates a plugin's use is separate — a member can hold example at read without being able to install or remove anything.

And two things the platform will not do to a plugin:

  • Uninstall never drops a table or a row. Uninstall means "stop running this", and for a plugin holding a user's data the two are unrecoverably different. Reinstalling is a restore. The generated schema barrel follows plugin directories rather than the install table precisely so that this holds — which also means deleting a plugin's source and then running bun db:push will drop its tables. That is the only path that loses data, and it is deliberate that it requires deleting the code.
  • Uninstall never removes a host package. A package is a machine-wide resource that other things may have started depending on the moment it appeared.

There is no sandbox. A plugin's router runs inside the platform's process and its sidecar runs as a PM2 peer under the same OS user, which on a normal install is the owner. Installing a third-party plugin is trusting its code with everything the owner has. The permission system decides which accounts reach a plugin; it does not constrain the plugin itself.

Installing

From the marketplace

The plugins page lists what the catalogue offers alongside what is already on this machine. Installing something not yet present clones it first — shallow, over anonymous https, into plugins/<appName>/ — then re-reads the manifest from disk. The catalogue describes a plugin; the manifest on disk is what the platform obeys, and a catalogue that disagrees with it is a catalogue bug rather than a second source of truth.

A clone is refused when the directory already exists, because that directory may be a plugin somebody is developing and git clone over the top of local work is a loss no undo covers.

For development

Put the directory in plugins/ yourself. Being on disk and being installed are different states: a plugin you are writing sits in the tree, appears on /plugins, and mounts nothing until you install it from that page. Install then skips the clone and proceeds with what is there.

cd ~/officer/platform/plugins
git clone https://gitea.officer.dev/plugins/example.git
pm2 restart officer

Three things to know before you lose time to them:

  • A manifest is read once per server process. Discovery does a dynamic import and the module cache holds it for the life of the process, so editing manifest.ts changes nothing until pm2 restart officer — while the plugins page keeps cheerfully showing the old values.
  • Install, enable and disable rebuild the SPA (about a second) and rewrite the routes in place. You do not need to restart the platform for a code change to a panel or a route, only for a manifest change. A normal refresh picks up the new bundle.
  • plugins/*/ is gitignored by the platform. A cloned plugin carries its own .git, and tracking it would turn git add -A into a commit of a pointer to a commit the platform's repository does not have. Your plugin is its own repository with its own remote.

Further reading

  • plugins/offscale/PLUGIN.md — the worked example. Every decision in the plugin system, with the reasoning, kept next to the plugin it produced.
  • plugins/EXTRACTING-A-PLUGIN.md — the runbook for pulling an existing feature out of the platform.
  • src/servers/plugins/ in the platform — the system itself. discover.ts is the authority on the file tree above, manifest.ts on the fields, install.ts on what installing actually does and in what order.
S
Description
The reference Officer plugin — the smallest thing that is still a real plugin
Readme
46 KiB
Languages
TypeScript 100%