Third-party Plugin Development
PTPatronus can stay closed source while exposing a broad external plugin platform. Third-party plugins run as isolated processes and communicate with the host over HTTP.
Quick start
Create a plugin template:
python backend/tools/pluginctl.py create my-demo-plugin --directory data/plugins/my-demo-pluginCreate a TypeScript plugin with a packaged web view:
python backend/tools/pluginctl.py create my-ts-plugin --directory data/plugins/my-ts-plugin --template typescript --with-viewCreate a self-contained plugin repo that vendors the official tooling and CI workflows:
python backend/tools/pluginctl.py create my-plugin-repo --directory my-plugin-repo --template typescript --with-view --vendor-tools --with-ci bothThen install the local toolchain once and build the runtime bundle:
cd data/plugins/my-ts-plugin
npm install
npm run buildFor that scaffold:
src/builds the plugin server intodist/web-src/builds the plugin view intoweb/npm run build:viewrebuilds only the frontend view assetsnpm run buildrebuilds both halves
Start PTPatronus, open the plugin page, then reload and enable the new plugin.
For a tight local loop, run the source watcher:
python backend/tools/pluginctl.py dev my-plugin-repo --plugin-root data/pluginsThis validates the source tree, mirrors it into data/plugins/{plugin_id}, and keeps syncing on change. If the plugin root contains a package.json with a build script, pluginctl.py dev will run that build automatically before each sync.
For compiled plugins such as TypeScript, run a build step before each sync:
python backend/tools/pluginctl.py dev my-ts-plugin --plugin-root data/plugins --build-command "npm run build"Use --skip-build if you want to disable the automatic package.json build detection for a specific run.
For an existing standalone plugin repository, you can vendor the official tool bundle and write workflow templates later:
python backend/tools/pluginctl.py vendor-tools .
python backend/tools/pluginctl.py ci . --provider both --vendor-toolsThe generated workflow always validates and packages the plugin. On GitHub tag builds it also uploads the zip, dist/plugin-package.json, and any generated dist/plugin-market.json to a GitHub Release.
Useful CI variables and secrets:
PTP_APP_VERSION: optional host version used when validatingapp_versionconstraints in a standalone plugin repoPTP_PLUGIN_MARKET_PRIVATE_KEY: optional base64 Ed25519 seed used to sign archivesPTP_PLUGIN_MARKET_NAME: optional market display namePTP_PLUGIN_MARKET_ARCHIVE_REF: optional archive URL/path written intoplugin-market.json; GitHub workflows auto-fill a release URL on tag builds if this is blankPTP_PLUGIN_PUBLISHER_ID,PTP_PLUGIN_PUBLISHER_NAME,PTP_PLUGIN_PUBLISHER_WEBSITE,PTP_PLUGIN_PUBLISHER_VERIFIEDPTP_PLUGIN_PUBLISHED_AT: optional explicit release timestampPTP_PLUGIN_CHANGELOG_FILE: optional text file path whose non-empty lines become repeated--changelogentries
Validate a plugin:
python backend/tools/pluginctl.py validate data/plugins/my-demo-pluginRun a mock Host API without starting the full application:
python backend/tools/pluginctl.py mock-host --port 19091 --token dev-tokenRun a plugin in a standalone local sandbox that mimics the host runtime:
python backend/tools/pluginctl.py run examples/plugins/external-http-demo --action ping --input '{"message":"hello"}' --onceThis starts a mock Host API, injects the same runtime environment variables as the application, waits for /health, and can immediately publish an event or invoke an action.
For shell-friendly input on Windows, object arguments also accept key=value pairs:
python backend/tools/pluginctl.py run examples/plugins/external-http-demo --action ping --input message=hello --oncePackage a plugin:
python backend/tools/pluginctl.py package data/plugins/my-demo-plugin --out-dir dist/pluginsThe package command prints a market-ready JSON fragment with the archive path, SHA-256 hash, manifest metadata, and inferred compatibility data such as host_capabilities. If the plugin directory has a package.json build script, package will run it automatically before archiving unless --skip-build is provided.
Use --json-out dist/plugin-package.json when CI or release automation needs the same metadata as a file.
Generate a market signing keypair:
python backend/tools/pluginctl.py keygen --private-key-out .keys/plugin-market.private.key --public-key-out .keys/plugin-market.public.keySign any existing archive:
python backend/tools/pluginctl.py sign dist/plugins/my-demo-plugin-0.1.0.zip --private-key .keys/plugin-market.private.keyPackage, sign, and upsert a market index in one step:
python backend/tools/pluginctl.py package data/plugins/my-demo-plugin --out-dir dist/plugins --private-key .keys/plugin-market.private.key --market dist/plugin-market.json --market-name "Acme Plugin Market" --publisher-id acme --publisher-name "Acme Plugins" --publisher-website https://example.com --publisher-verified --changelog "Initial release"keygen writes raw base64 Ed25519 key material. package --market keeps the archive reference relative to the market JSON unless --archive-ref is provided explicitly.
Dependency semantics:
- Dependencies are required by default.
- Add
"optional": trueto surface a dependency in UI without blocking install or enable. - Dependency version constraints use the same loose syntax as
app_version. - Disabling a plugin is blocked while enabled plugins still require it.
- Uninstalling a plugin is blocked while installed plugins still require it.
Development hot reload
In debug mode, or when PLUGIN_DEV_WATCH=1 is set, the host watches data/plugins/* and will:
- auto-load new external plugin directories
- auto-reload a plugin after source changes
- auto-unload a plugin after its source directory is removed
The poll interval defaults to 1000 ms and can be changed with:
PLUGIN_DEV_WATCH_INTERVAL_MS=500If your plugin source lives outside the application data directory, use pluginctl.py dev to mirror it into the watched plugin root. --build-command lets the same watcher support TypeScript or any other compiled plugin workflow.
For isolated debugging without starting PTPatronus, use pluginctl.py run.
Runtime model
External plugins declare:
{
"runtime": "external-http",
"api_version": "1",
"app_version": ">=0.1.0",
"host_capabilities": ["host.runtime.external-http.command", "host.ui.bridge"],
"optional_host_capabilities": ["host.api.notice.write"],
"dependencies": [
{ "id": "json-toolbox", "version": ">=1.0.0", "reason": "Shared JSON transforms" }
],
"entry": {
"command": "python",
"args": ["plugin.py"],
"health": "/health",
"shutdown": "/shutdown"
}
}Compatibility rules:
api_versionmust exactly match the host plugin API version.app_versionmay be blank or use comma-separated constraints such as>=0.81.0, <1.0.0.host_capabilitiesis optional and may be used to require specific host surfaces such as plugin views, bridge APIs, scheduling, or managed process launch.optional_host_capabilitieslets a plugin advertise best-effort integrations that should not block install or enable.dependenciesdeclares required or optional plugin prerequisites. Required dependencies must be installed, enabled, and version-matched before this plugin can be enabled.- Supported operators are
>,>=,<,<=,=and==. - The host auto-infers some capability requirements from the manifest itself, for example
permissions,contributes.views,schedule, andentry.command/entry.base_url. - The backend, market installer, and
pluginctl.py validateall enforce the same compatibility checks. - Vendored standalone
pluginctl.pycopies can still validate manifests outside the main PTPatronus repo. SetPTP_APP_VERSIONin CI if you wantapp_versionconstraints checked against a specific host release.
The host starts the process and injects:
PTP_PLUGIN_IDPTP_PLUGIN_PORTPTP_PLUGIN_TOKENPTP_PLUGIN_BASE_URLPTP_PLUGIN_DIRPTP_HOST_URLPTP_HOST_TOKENPTP_HOST_PROTOCOLPTP_HOST_PERMISSIONSPTP_HOST_CAPABILITIES
The plugin implements:
GET /healthPOST /eventPOST /action
The machine-readable manifest schema is in:
backend/tools/plugin-manifest.schema.jsonThe host calls /event and /action with this shape:
{
"action": "ping",
"input": {},
"config": {},
"host": {
"protocol_version": 1,
"plugin_id": "my-demo-plugin",
"base_url": "http://127.0.0.1:12345",
"token": "temporary-token",
"permissions": ["log:write", "kv:read", "kv:write"],
"host_capabilities": ["host.api.log.write", "host.api.kv.read", "host.api.kv.write"]
}
}Command-launched plugins can use the host environment variables. base_url plugins can use the host field in the request payload.
Host API
All Host API requests use:
Authorization: Bearer <PTP_HOST_TOKEN>Available endpoints:
GET /capabilitiesGET /configPOST /runtime/configPOST /logPOST /noticePOST /eventsGET /kv/{key}PUT /kv/{key}DELETE /kv/{key}GET /sitesGET /sites/{id}/cookie
Permissions:
config:readconfig:writelog:writenotice:writeevent:publishkv:readkv:writekv:deletesite:readsite:cookiemedia:search— call the host media chain (POST /media/search {keyword, year}→{results:[{source,source_id,title,year,type,overview,poster_url,backdrop_url,rating,genres}]}). Scrapers reuse the host's TMDB/豆瓣/寸光集 sources instead of shipping their own TMDB key.
Wildcards are supported for trusted plugins:
host:*plugin:**
Common host capability names:
host.runtime.external-http.commandhost.runtime.external-http.base-urlhost.event.subscriptionhost.schedule.cronhost.api.config.readhost.api.config.writehost.api.log.writehost.api.notice.writehost.api.event.publishhost.api.kv.readhost.api.kv.writehost.api.kv.deletehost.api.site.readhost.api.site.cookiehost.ui.viewhost.ui.bridge
Manifest declarations & runtime patterns
Resource-access declarations (resource_access)
External-http plugins run as unrestricted host subprocesses (same OS user, full filesystem + subprocess + outbound network) — there is no OS-level sandbox. To keep administrators informed, declare the resources a plugin touches in resource_access; these are shown on the install-confirmation page (declared, not enforced):
{
"resource_access": {
"fs_paths": ["/data/media"],
"exec_binaries": ["ffmpeg", "ffprobe"],
"net_hosts": ["ptang.top", "api.themoviedb.org"]
}
}Python dependencies (requirements)
A plugin can declare pip requirements; on install the host runs pip install --target {plugin_dir}/.deps and prepends .deps to PYTHONPATH when launching the plugin. Best-effort: if pip is absent or install fails, install still succeeds (the plugin may run on stdlib alone).
{ "requirements": ["clouddrive", "Pillow>=10"] }Long-running work must be async
The host caps each /action call at ~30s. Batch jobs (scanning a whole library, scraping many items) must return immediately and run the work on a background thread, reporting progress via KV/log and completion via notice. Expose a status action for polling. Pattern:
import threading
_worker_lock = threading.Lock()
_worker_thread = None
_worker_status = {"running": False, "done": False}
def _worker(cfg):
try:
result = do_the_work(cfg, _worker_status) # updates _worker_status as it goes
_worker_status.update({"running": False, "done": True})
notice("done", str(result))
except Exception as e:
_worker_status.update({"running": False, "done": True, "error": str(e)})
def start_scan(cfg):
global _worker_thread
with _worker_lock:
if _worker_thread and _worker_thread.is_alive():
return {"started": False, "already_running": True}
_worker_thread = threading.Thread(target=_worker, args=(cfg,), daemon=True)
_worker_thread.start()
return {"started": True}For external HTTP, urllib.request.urlopen(timeout=) only guards each recv — guard the total time with a thread + join(deadline) so a throttled/slow-drip response can't wedge the worker.
Plugin-view → action bridge
Plugin views (iframes) can call a declared action and render its output via the injected bridge — enables dashboards that pull live data from the plugin:
const res = await window.PTPatronus.action('info', { name: 'instance1' })
// res.output is whatever the action returnsTypeScript SDK
The TypeScript SDK lives in sdk/typescript.
import { PTPHostClient, type ActionRequest } from '@ptpatronus/plugin-sdk'
export async function handleAction(payload: ActionRequest) {
const host = PTPHostClient.fromPayload(payload) ?? PTPHostClient.fromEnv()
if (!host?.hasCapability('host.api.kv.write')) throw new Error('Host KV write support is required')
await host?.log('info', 'my-plugin.action', 'started')
const current = await host?.kvGet<number>('count')
const next = (current?.value ?? 0) + 1
await host?.kvSet('count', next)
return { ok: true, output: { next } }
}Distribution
External plugins are installed under:
data/plugins/{plugin_id}A market index points to zip archives:
{
"version": 1,
"public_key": "base64-encoded-ed25519-public-key",
"api_version": "1",
"app_version": "0.81.01",
"host_capabilities": ["host.runtime.external-http.command", "host.ui.view", "host.ui.bridge"],
"plugins": [
{
"id": "my-demo-plugin",
"name": "My Demo Plugin",
"version": "0.1.0",
"host_capabilities": ["host.runtime.external-http.command", "host.ui.view", "host.ui.bridge"],
"optional_host_capabilities": ["host.api.notice.write"],
"dependencies": [
{ "id": "json-toolbox", "version": ">=1.0.0" }
],
"publisher": {
"id": "acme",
"name": "Acme Plugins",
"website": "https://example.com",
"verified": true
},
"published_at": "2026-07-09T00:00:00Z",
"changelog": ["Added dashboard view", "Improved retry logic"],
"runtime": "external-http",
"archive": "archives/my-demo-plugin-0.1.0.zip",
"sha256": "...",
"signature": "base64-encoded-ed25519-signature"
}
]
}Unsigned markets are valid for local development. Once public_key is set, every plugin entry in that market must also carry a matching signature.
Market trust is surfaced to administrators as:
verified-signedsignedpublisher-verifiedunsigned
Install requests sent through the application API must include the permission set the administrator accepted:
{
"source": "data/plugin-market.json",
"id": "my-demo-plugin",
"accepted_permissions": ["log:write", "kv:read", "kv:write"],
"accepted_permissions_by_plugin": [
{ "id": "json-toolbox", "accepted_permissions": ["kv:read"] },
{ "id": "my-demo-plugin", "accepted_permissions": ["log:write", "kv:read", "kv:write"] }
]
}The backend rejects the install if any accepted permission set does not match the market install plan.
Upgrade and rollback
Market installs now keep local release snapshots under:
data/../plugin-releases/{plugin_id}/Each successful install or upgrade records a local release snapshot. Rolling back restores the selected snapshot without re-downloading the plugin package.
Official plugin market
A single plugin's CI already emits a signed plugin-package.json (one MarketPlugin entry) via package --json-out. An official market holds many plugins, so pluginctl.py market aggregates those per-plugin files into one plugin-market.json and scaffolds the market repository.
Aggregate multiple plugins
python backend/tools/pluginctl.py market aggregate \
--packages-dir packages \
--public-key keys/plugin-market.public.key \
--out plugin-market.json \
--name "PTPatronus Plugin Market"--package <path>(repeatable) and/or--packages-dir <dir>select the inputplugin-package.jsonfiles. A directory scan keeps any JSON object that hasid,version, andarchive.--keep latest(default) keeps only the highest version per plugin id (with a warning listing the dropped versions);--keep allkeeps every version.--public-key(base64 or a path) produces a signed market. Every input entry must then carry asignature(the all-or-nothing rule the host verifies withverifySignature); an unsigned entry aborts the build. Omit it for a local unsigned market, in which case entries must not carry signatures.--archives-dir <dir>is an optional integrity gate: for each archive resolvable in that directory the aggregator checkssha256and (for signed markets) the signature, aborting on mismatch. Archives not present locally are skipped with a warning — the host re-verifies bytes at install time regardless, so this is a publish-time safety net, not a requirement.--private-key <key>(+--archives-dir) switches to central re-sign: the operator's market key re-hashes and re-signs every archive on disk, overwriting whatever signature each plugin arrived with. This is the curated-market model — authors submit unsigned zips and the market signing key never leaves the operator. It requires every archive to be present locally.--release-base <url>optionally rewrites every entry'sarchiveto{release_base}/{id}-v{version}/{id}-{version}.zip(--archive-patternoverrides the template with{release_base},{id},{version},{name}). Without it the aggregator keeps each entry's existing archive URL verbatim — which is what you want when each plugin's CI already filled a full release URL.
The output mirrors the index shape above: version, name, api_version, app_version, host_capabilities, public_key (signed markets only), and a plugins array sorted by id.
Repository layout and flow
An official market repo holds metadata only — the zip archives continue to live in each plugin's own GitHub Releases:
plugin-market.json # aggregated index (generated; commit this)
packages/ # submitted plugin-package.json, one per plugin
keys/plugin-market.public.key # market Ed25519 public key (committed trust root)
keys/plugin-market.private.key # matching seed (gitignored; CI secret only)
tools/ptpatronus/pluginctl.py # vendored tool used to regenerate the index
.github/workflows/aggregate.yml # rebuilds the index on changes to packages/The end-to-end flow:
- Each plugin's CI runs
package ... --json-out dist/plugin-package.jsonwithPTP_PLUGIN_MARKET_PRIVATE_KEYset to the market signing key, so the metadata arrives already signed by the market key. - Drop the result into the market repo as
packages/<plugin-id>.json. aggregate.ymlrunsmarket aggregateon push and commits the regeneratedplugin-market.json.
Scaffold a new market repo
python backend/tools/pluginctl.py market init my-market \
--name "My Plugin Market" \
--generate-keys \
--release-base https://github.com/<org>/<market>/releases/downloadThis writes the layout above (vendoring pluginctl.py, generating the keypair, and seeding an empty index and aggregate workflow). A reference sample lives under examples/plugin-market/. Keep plugin-market.private.key secret — it is gitignored and only ever provisioned to plugin CI as PTP_PLUGIN_MARKET_PRIVATE_KEY.
UI contribution metadata
The manifest can already declare UI contribution intent, even while individual host UI surfaces are introduced over time:
{
"contributes": {
"commands": [
{ "id": "my-plugin.ping", "label": "Ping", "action": "ping" }
],
"menus": [
{ "location": "plugins/actions", "command": "my-plugin.ping" }
],
"views": [
{ "id": "my-plugin.panel", "title": "My Panel", "location": "sidebar", "path": "/panel" }
]
}
}Plugin view assets are loaded from the plugin package web/ directory through the authenticated plugin asset API. For example:
data/plugins/my-plugin/web/index.htmlRelative script, link, img, and similar asset URLs inside that HTML are resolved against the same plugin asset directory, so multi-file frontend bundles under web/ work as expected.
and:
{
"contributes": {
"views": [
{ "id": "dashboard", "title": "Dashboard", "location": "plugins", "path": "index.html" }
]
}
}The frontend renders plugin HTML in a sandboxed iframe using srcdoc. View-based plugins should declare or rely on inferred host.ui.view and host.ui.bridge capability requirements so markets can preflight them before installation.
Plugin view bridge
Plugin views receive an injected window.PTPatronus object. The iframe never receives host tokens directly; requests are forwarded by the parent page to the protected backend bridge API.
<script>
async function run() {
const current = await window.PTPatronus.kv.get('count')
const next = Number(current.value || 0) + 1
await window.PTPatronus.kv.set('count', next)
await window.PTPatronus.log('info', 'ui.run', 'counter updated', { next })
await window.PTPatronus.publish('plugin.demo.ui', { next })
}
</script>Bridge methods:
PTPatronus.capabilities()PTPatronus.config.get()PTPatronus.log(level, event, message, data)PTPatronus.notice(title, body, level?)PTPatronus.publish(type, data)PTPatronus.kv.get(key)PTPatronus.kv.set(key, value)PTPatronus.kv.delete(key)PTPatronus.sites.list()
Bundled plugin frontends can also use the TypeScript SDK instead of the injected global directly:
import { PTPPluginViewClient } from '@ptpatronus/plugin-sdk'
const host = PTPPluginViewClient.fromWindow()
if (!host) throw new Error('plugin view bridge unavailable')
const caps = await host.capabilities()
if (host.hasPermission('kv:write')) {
const current = await host.kvGet<number>('count')
await host.kvSet('count', Number(current.value || 0) + 1)
}