How to write a PluginForge plugin. Target audience: a developer authoring a plugin that runs against a PluginForge-based host application.
Status: Skeleton. Sections marked
TODO: expand in v0.9.0were not expanded in v0.9.0; the expansion is deferred to a later documentation pass. The Single-Router Convention section is complete because of its tie to the P0.5 deprecation warning shipped in v0.8.0.
Every plugin is a subclass of pluginforge.BasePlugin. The host
application instantiates your class, calls init(), optionally
gates by identity and version, then calls activate(). On shutdown,
deactivate() runs in reverse activation order.
Required class attributes:
name: str- unique identifier within the host's plugin set. Lowercase, no path separators, max 64 characters (pluginforge.securityenforces this).version: str- your plugin's version. Default"0.1.0".api_version: str- PluginForge hook spec compatibility version. Default"1"; bump only when PluginForge releases a new major hook-spec version.
Recommended class attributes:
target_application: str | None- identifier of the host application your plugin targets (e.g."bibliogon"). Required for v0.9.0+ when the host has adoptedapp_id. See Identity gating.min_app_version: str | None- minimum host version your plugin supports. PluginForge filters at activation if the host'sapp_versionis below this.description: str,author: str- free text for tooling.depends_on: list[str]- other plugins your plugin requires. PluginForge sorts the activation order topologically.
TODO: expand in v0.9.0 - worked example of a full plugin file with all attributes, plus an annotated walkthrough of the activation flow.
Declare target_application to opt into v0.7.0 identity protection:
from pluginforge import BasePlugin
class MyPlugin(BasePlugin):
name = "myplugin"
version = "1.0.0"
target_application = "myapp" # match the host's app_id
min_app_version = "0.7.0"When the host constructs PluginManager(app_id="myapp", ...):
- A plugin with
target_application = "myapp"activates. - A plugin with
target_application = "otherapp"filters out withFilterReason.wrong_application. - A plugin without
target_applicationfilters out in v0.9.0+ withFilterReason.missing_target_application(was a deprecation warning in v0.7.0/v0.8.0; v0.9.0 shipped the hard-filter transition).
When the host has not set app_id, identity is not validated and
your declaration is ignored. The host opts in by declaring its
identity.
TODO: expand in v0.9.0 - migration recipe for plugin authors who shipped without
target_applicationon a v0.6.x host.
Rule: return exactly one router from get_routes(). Nest
namespace-separated sub-routers via router.include_router(sub).
# Correct: one parent router, sub-routers nested.
from fastapi import APIRouter
panels_router = APIRouter(prefix="/panels")
panels_router.add_api_route("/", list_panels, methods=["GET"])
panels_router.add_api_route("/{panel_id}", get_panel, methods=["GET"])
bubbles_router = APIRouter(prefix="/bubbles")
bubbles_router.add_api_route("/", list_bubbles, methods=["GET"])
class ComicsPlugin(BasePlugin):
name = "comics"
def get_routes(self) -> list:
parent = APIRouter(prefix="/comics")
parent.include_router(panels_router)
parent.include_router(bubbles_router)
return [parent]# Discouraged: multiple top-level routers.
class ComicsPlugin(BasePlugin):
def get_routes(self) -> list:
return [panels_router, bubbles_router] # DeprecationWarning in v0.8.0+Rationale. The discouraged shape compounds with the v0.7.0
route-mount cascade (P0): each top-level router gets its own
include_router call on the host's FastAPI app. With many plugins
shipping many top-level routers, route accumulation across
TestClient lifespans caused RecursionError in long pytest
sweeps (see the brief).
v0.8.0's idempotent mount fix removes the cascade, but the
convention stays in place for ecosystem coherence: a plugin's
routes live under one URL namespace per plugin, mountable as one
unit.
Deprecation timeline:
- v0.7.0 and earlier: multiple top-level routers supported, silent.
- v0.8.0:
DeprecationWarningemitted on multi-router plugins. - v0.9.0 and v0.10.0: warning continues.
- v0.11.0 (earliest): may become an error.
Per-plugin configuration lives in
config/plugins/{your_plugin_name}.yaml on the host. PluginForge
loads it during init() and passes it as the second argument.
class MyPlugin(BasePlugin):
name = "myplugin"
config_schema = {
"max_items": int,
"endpoint_url": str,
}
def activate(self) -> None:
self.max_items = self.config.get("max_items", 100)
self.endpoint = self.config.get("endpoint_url", "")config_schema is a dict of {key: expected_type}. PluginForge
validates types during init() and raises if a key has the wrong
type. Missing keys are not errors; provide defaults in activate().
TODO: expand in v0.9.0 - schema-evolution patterns when adding or removing config keys across plugin versions.
Declare depends_on to require other plugins to activate before
yours:
class ExportPlugin(BasePlugin):
name = "export"
depends_on = ["storage"]PluginForge sorts plugins topologically and activates dependencies
first. Cycles raise CircularDependencyError. Missing dependencies
filter your plugin out at activation.
PluginForge plugins run in the same Python process and can interact through three patterns, listed in order of preference. For the reference treatment see the Cross-Plugin-Communication wiki page.
The host application defines hookspecs; plugins implement them via
@hookimpl. The host dispatches hooks at the right lifecycle moment.
Plugins don't need to know about each other. This is pluggy's native
mechanism and PluginForge's primary extension model.
When to use: plugin provides a capability the host orchestrates (export formats, content transforms, AI completions).
Example: a host defines an export_execute hookspec. Any plugin can
implement it. The host's export router dispatches it. A new plugin
adding a PDF export format just implements the hook, no imports from
other plugins needed.
Limitation: hooks are one-shot dispatch calls. If a plugin needs a multi-method service object from another plugin, hooks are too coarse.
Plugin B imports from Plugin A's package directly. Simple, explicit, no framework abstraction needed.
When to use: two plugins are developed together, share a release cycle, and the dependency is stable. Typically first-party plugins in a monorepo.
Constraints:
- Declare
depends_on = ["plugin-a"]so PluginForge activates A before B. - Handle
ImportErrorgracefully if the dependency might not be installed (optional cross-plugin feature). - This pattern does NOT work safely for external/third-party plugins because the imported plugin's internal API is not guaranteed stable.
For cases where plugins need typed service objects from other plugins without direct imports. Not yet implemented in PluginForge. If your use case requires this, use hookspecs or direct imports for now.
The recommended path for most applications: define hookspecs in the
host for every cross-cutting concern, and let plugins implement them.
depends_on handles activation ordering. Direct imports are
acceptable for first-party plugins under your control.
| Hook | When called | Override if |
|---|---|---|
init(app_config, plugin_config) |
Once, before activation | You need both app and plugin config. The default stores them on self. |
activate() |
After successful init, in dependency order | You need to set up state, open connections, register external handles. |
deactivate() |
On shutdown, reverse activation order | You hold resources that need explicit release. |
on_config_changed(old, new) |
When the host calls refresh_config |
You cache config-derived state and need to invalidate it. |
health() -> dict |
When the host calls health_check |
You want to expose plugin-specific health beyond {"status": "ok"}. |
get_routes() -> list |
Once, during mount_routes |
You expose FastAPI endpoints. Follow the Single-Router Convention. |
get_frontend_manifest() -> dict | None |
On consumer request | You ship frontend assets. |
get_migrations_dir() -> str | None |
When the host calls collect_migrations |
You ship Alembic migrations. |
TODO: expand in v0.9.0 - failure-recovery patterns. If
activate()raises, PluginForge catches the exception (since v0.6.0), logs at ERROR, and continues with siblings. Your plugin lands with statuserrorin theDiscoveryResult. Cover the recovery story for stateful plugins.
Two installation paths. Pick the one your host supports.
Entry-point installed (pip-installable): declare your plugin
class in [project.entry-points."<group>"] in pyproject.toml. The
group must match the host's plugins.entry_point_group value.
[project.entry-points."myapp.plugins"]
myplugin = "myplugin.main:MyPlugin"TODO: expand in v0.9.0 - direct-register / ZIP-install pattern for hosts that support uploaded plugins (Bibliogon-specific today).
- Mandatory class attributes: name, version, target_application
- Single router convention: get_routes() returns exactly one APIRouter (multi-router emits DeprecationWarning since v0.8.0)
- Common mistakes: multiple routers from get_routes(), missing target_application (deprecation warning in v0.7.0/v0.8.0, hard filter in v0.9.0+), not implementing on_config_changed when plugin has stateful config
- Key files: pluginforge/base.py (BasePlugin contract), pluginforge/state.py (FilterReason, ErrorPhase enums)
BasePluginsource:pluginforge/base.py.PluginManagersource:pluginforge/manager.py.- Consumer-app guide: docs/guides/consumer-integration.md.
- Architecture:
docs/ARCHITECTURE.md. - Per-release design rationale:
docs/design/.