This repository contains demo plugins for the Multiforum platform.
Docs are split by audience:
- Server admins:
SERVER_ADMIN_GUIDE.md - Plugin developers:
PLUGIN_DEVELOPER_GUIDE.md
Plugins in this repo demonstrate how to extend Multiforum using hooks that run when content is created or updated (comments, downloadable files, and more).
At this stage, the repo contains four example plugins:
-
Security: Attachment Scan
A server-scoped plugin that scans uploaded attachments against the VirusTotal API.
Requires a secret (VIRUS_TOTAL_API_KEY) to be configured by the site admin. -
Hello World
A simple channel-scoped plugin that proves execution works at the forum level.
When enabled, it logs a message each time a file attachment event is triggered. -
ChatGPT Bot Profiles
A channel-scoped plugin that responds to/bot/<handle>mentions in comments with configurable ChatGPT profiles.
Requires a secret (OPENAI_API_KEY) to be configured by the site admin. -
Beta Reader Bot
A channel-scoped plugin that responds to/bot/betabotmentions with creative writing feedback profiles.
Requires a secret (OPENAI_API_KEY) to be configured by the site admin.
- Each plugin has its own folder under
plugins/. plugin.jsondeclares the plugin manifest (id, name, version, entry file, required events, secrets).- TypeScript source lives alongside
plugin.json(for example,index.ts). dist/contains compiled JavaScript for execution (the entry point listed inplugin.json).
{
"id": "security-attachment-scan",
"name": "Security: Attachment Scan",
"version": "0.1.0",
"entry": "dist/index.js",
"events": ["comment.created"],
"secrets": [
{ "key": "OPENAI_API_KEY", "scope": "server", "required": true }
]
}- Node.js ≥ 18
- npm / pnpm / yarn
- TypeScript
From the repo root:
# Install deps for a single plugin
npm run install:plugin -- --plugin hello-world
# Build a single plugin
npm run build:plugin -- --plugin hello-worldThis generates dist/index.js referenced by each manifest.
These scripts run per-plugin installs/builds using pnpm workspaces:
# Install dependencies for one plugin
npm run install:plugin -- --plugin <id>
# Build one plugin
npm run build:plugin -- --plugin <id>You can still build everything with npm run build, which runs each plugin build in sequence.
npm run lint:manifestsRun the validator before committing to ensure each manifest declares metadata, documentation paths, and UI configuration used by the admin screens.
npm run bundle:create -- --plugin <id>bundles a single plugin using its manifest version.npm run registry:generate -- --plugin <id>merges that plugin version intoregistry.json.
- Bump the
versioninside the plugin’splugin.jsonbefore building. - Build only the plugin you are releasing so the tarball bundles the updated manifest.
- After uploading the bundle, sanity-check the embedded manifest:
The manifest version is the source of truth. The
gsutil cat gs://<bucket>/plugins/<id>/<version>/plugin.json
versionfield must match the<version>directory and the version entry inregistry.json. If they differ (for example, the manifest still says0.2.0but the registry lists0.2.1), installs fail andrefreshPluginscreates mismatched version records. - Update
registry.jsonby merging the new version into existing entries (do not overwrite other plugins or older versions).
-
A Multiforum admin points the server at a plugin registry (JSON in GCS).
-
Plugins from this repo appear under Allowed Plugins in the server’s Plugin Library UI.
-
Admin can enable server-scoped plugins and enter secrets (e.g., VirusTotal key).
-
Admin can allow channel-scoped plugins; channel owners can then enable them per forum.
-
When content is posted:
- Server-enabled plugins run (e.g., Attachment Scan).
- Channel-enabled plugins run (e.g., Hello World).
-
Results are visible in a Pipelines panel: each check (plugin) shows success/failure and logs.
Plugins with the bot tag create and maintain bot users when they are enabled at the channel scope. The backend will:
- Create bot users for every profile configured for the channel.
- Connect existing bot users to the channel if missing.
- Disconnect bot users that are no longer listed in the configured profiles.
Required settings format (per channel or server):
botName(string): handle used to build the bot username.profiles(array) orprofilesJson(string JSON array).
Each profile entry must include:
id(string, required)label(string, optional) — used in display names
Example profiles JSON:
[
{ "id": "general", "label": "General Assistant", "prompt": "Helpful, concise replies." }
]Notes:
- If both
profilesandprofilesJsonexist,profilestakes precedence. - If
botNameis missing or empty, no bot users are created.
This repo publishes deterministic tarballs per plugin+version to Google Cloud Storage (GCS) using plugin-scoped releases. Multiforum reads a registry.json in the bucket to list what’s available.
This documentation assumes the publishing scripts/CI have been updated to support per-plugin builds and registry merging.
gs://mf-plugins-prod/
registry.json
plugins/
security-attachment-scan/
0.1.0/
bundle.tgz
bundle.sha256
plugin.json # optional convenience copy of the manifest
hello-world/
0.1.0/
bundle.tgz
bundle.sha256
plugin.json
You can also store
plugins/<id>/latest.json→{ "version": "0.1.0" }as a convenience (optional). The optionalplugin.jsonfile above is just a readable copy of the manifest so humans (or tooling) can inspect the version without unpacking the tarball.
Each bundle.tgz includes only what the worker needs:
plugin.json
dist/index.js
# optionally: dist/*.map, README.md
We make tarballs deterministic so their SHA256 hash is stable (sort entries, zero timestamps, numeric owners).
- The plugin manifest version (
plugin.json.version) is the source of truth. - Release a single plugin at a time:
- Recommended tag format:
<plugin-id>@<version>(e.g.hello-world@0.2.2). - CI validates that the tag version matches
plugin.json.version.
- Recommended tag format:
- CI builds only the tagged plugin, uploads its tarball to:
gs://<bucket>/plugins/<id>/<version>/bundle.tgz - CI then merges the new version into
registry.jsoninstead of overwriting it.
- Keep the bucket private.
- CI authenticates using Workload Identity Federation (preferred) or a service account key.
- Multiforum API/Worker run with a GCP service account that has
storage.objects.get(and optionallylistto read the registry).
Secrets required in repo settings:
WIF_PROVIDER– Workload Identity Federation provider resource name.GCP_SA_EMAIL– GCP service account email that can upload to the bucket.
The registry.json object uploaded by CI looks like:
{
"updatedAt": "2025-08-26T21:22:30-07:00",
"plugins": [
{
"id": "security-attachment-scan",
"versions": [
{
"version": "0.2.1",
"tarballUrl": "gs://mf-plugins-prod/plugins/security-attachment-scan/0.2.1/bundle.tgz",
"integritySha256": "e3b0c44298fc1c149afbf4c8996fb924..."
}
]
},
{
"id": "hello-world",
"versions": [
{
"version": "0.2.2",
"tarballUrl": "gs://mf-plugins-prod/plugins/hello-world/0.2.2/bundle.tgz",
"integritySha256": "ab12cd34ef56..."
}
]
}
]
}Multiforum uses this to list plugins and install a chosen id@version. During install, the server downloads the tarball from GCS, verifies the SHA256, and records the GCS URL + hash in its database. Workers fetch the tarball by gs:// path at runtime, verify again, extract, and run dist/index.js.
Notes:
- Registries should preserve older versions when new ones are published.
- Multiple registries are supported; each registry can host any subset of plugins.
If you want to test end-to-end before wiring CI, publish one plugin at a time:
# 1. Build the plugin (dist outputs will be packaged)
cd plugins/hello-world
npm install
npm run build
# 2. Create deterministic bundle for this plugin's manifest version
# (example assumes plugin.json.version = 0.2.2)
cd ../..
npm run bundle:create -- --plugin hello-world
# 3. Merge registry.json (override bucket if needed)
npm run registry:generate -- --plugin hello-world --bucket gs://mf-plugins-prod --output registry.json
# 4. Upload bundle + hash + merged registry
BUCKET=mf-plugins-prod
VERSION=0.2.2
gsutil cp "out/hello-world-${VERSION}.tgz" "gs://${BUCKET}/plugins/hello-world/${VERSION}/bundle.tgz"
gsutil cp "out/hello-world-${VERSION}.sha256" "gs://${BUCKET}/plugins/hello-world/${VERSION}/bundle.sha256"
gsutil cp registry.json "gs://${BUCKET}/registry.json"- Create a new folder under
plugins/<id>/. - Add a
plugin.jsonmanifest with required fields (id,name,version,entry,events). - Implement
index.tsand export a default handler. - Add a
package.jsonwith abuildscript that outputsdist/index.js. - Run
npm installthennpm run buildinside the plugin folder. - Verify
dist/index.jsexists and matches the manifestentry.
Minimum plugin.json example:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"description": "What it does",
"entry": "dist/index.js",
"events": ["comment.created"]
}Minimum index.ts example:
export default async function (ctx, event) {
ctx.log("Plugin ran for", event.type);
}Use this for either a new plugin or a version bump:
- Bump
plugin.json.version(andpackage.json.versionif present). - Build the plugin:
npm run build:plugin -- --plugin <id>. - Create the bundle:
npm run bundle:create -- --plugin <id>. - Merge the version into the registry:
npm run registry:generate -- --plugin <id> --bucket gs://<bucket> --output registry.json. - Upload bundle + sha256 + registry to GCS.
- (Optional) Upload the convenience
plugin.jsontogs://<bucket>/plugins/<id>/<version>/plugin.json.
- Create
plugins/<your-plugin>/. - Add a
plugin.jsonwithid,name,version,entry,events. - Implement
index.tsexporting a default class withhandleEvent:
export default class MyPlugin {
constructor(ctx) {
this.ctx = ctx;
}
async handleEvent(event) {
this.ctx.log("Hello from", this.ctx.scope, this.ctx.channelId);
// do work, e.g., this.ctx.storeFlag(...)
}
}- Compile to
dist/index.js. - Commit both
plugin.jsonanddist/. - Tag a release (e.g.,
hello-world@0.2.2) to trigger CI → publish to GCS.
MIT — unless otherwise noted in individual plugin folders.
---
If you want, I can also drop in a tiny `scripts/validate-manifests.ts` for the repo to fail CI if a plugin is missing `dist/index.js` or the manifest is malformed.