18 KiB
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:
appNameis 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. Aweb/directory missing eitherpanels.tsorlayout.tsmakes 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 rendersWorkspaceViewaround them. There is nowhere to put anything else.- The sidecar's runtime is its file extension.
sidecar/index.tsruns under bun,sidecar/index.mjsunder 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
-
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 initIt has to live inside the platform repository. That is load-bearing rather than tidy: Bun resolves
hooks/useClient,officerdev,officerdband 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. -
Edit
manifest.ts. Newlabel,summary,color, and a permission key that starts with your app name. Leavepublisherasofficerdevunless you have your own. -
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 inweb/panels.ts, theappTypes and ids inweb/layout.ts, and the component names are all yours to change. EveryappTypeinlayout.tsmust be akeyfrompanels.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. -
Delete what you do not need. No frontend? Remove
web/entirely — but do not remove only one ofpanels.tsorlayout.ts. No process? Removesidecar/. Both are absences, not settings. -
Add
db/schema.tsif you need tables, prefixing every table name with<appName>_. -
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
chatorterminalwould 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 kindapp: 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. readOnlyWriteslists paths that look like writes and are not — aPOST /ssh-testthat probes, aPOST /assistthat proposes and never saves. Without declaring them, an account holding the permission atreadmeets 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:pushon 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
osDependenciesand 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/pluginsrefuses 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 holdexampleat 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:pushwill 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.tschanges nothing untilpm2 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 turngit add -Ainto 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.tsis the authority on the file tree above,manifest.tson the fields,install.tson what installing actually does and in what order.