Plug-in author: getting started
This walkthrough is for a developer building their first vibe_erp plug-in. By the end you will have a JAR that drops into ./plugins/, owns its own database tables, registers HTTP endpoints, and shows up in the host's boot log.
For the conceptual overview of the plug-in API, read ../plugin-api/overview.md first. For the architectural reasoning behind the constraints, see ../architecture/overview.md. The most up-to-date worked example is the reference printing-shop plug-in at reference-customer/plugin-printing-shop/ in this repo — it exercises everything described below.
1. The only dependency you need
Add org.vibeerp:api-v1 and PF4J's Plugin base class. api-v1 is the only vibe_erp dependency a plug-in is allowed to declare — the host's PluginLinter will scan your JAR's bytecode at install time and reject it if it references anything in org.vibeerp.platform.* or org.vibeerp.pbc.*.
In Gradle (build.gradle.kts):
plugins {
kotlin("jvm") version "1.9.24"
}
repositories {
mavenCentral()
}
dependencies {
// The only vibe_erp dependency. Use `compileOnly` so it isn't bundled
// into your JAR — the host provides api-v1 at runtime via parent-first
// classloading.
compileOnly("org.vibeerp:api-v1:1.0.0")
// PF4J's Plugin base class — also `compileOnly`. The host provides it.
compileOnly("org.pf4j:pf4j:3.12.0")
}
// PF4J expects manifest entries to identify the plug-in.
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes(
"Plugin-Id" to "hello-print",
"Plugin-Version" to project.version,
"Plugin-Provider" to "Example Print Co.",
"Plugin-Class" to "com.example.helloprint.HelloPrintPlugin",
"Plugin-Description" to "Hello-print example plug-in",
"Plugin-Requires" to "1.x",
)
}
}
compileOnly is correct for both deps: at runtime the host's parent-first classloader serves them. Bundling either one inside your JAR causes ClassCastException at the host/plug-in boundary.
2. Implement the plug-in entry class
Your plug-in's main class needs to do two things at once:
-
Extend
org.pf4j.Pluginso PF4J's loader can instantiate it from thePlugin-Classmanifest entry. -
Implement
org.vibeerp.api.v1.plugin.Pluginso vibe_erp's lifecycle hook can callstart(context)after PF4J's no-argstart()runs.
The two have the same simple name Plugin but live in different packages, so use Kotlin import aliases:
package com.example.helloprint
import org.pf4j.PluginWrapper
import org.vibeerp.api.v1.plugin.HttpMethod
import org.vibeerp.api.v1.plugin.PluginContext
import org.vibeerp.api.v1.plugin.PluginRequest
import org.vibeerp.api.v1.plugin.PluginResponse
import org.pf4j.Plugin as Pf4jPlugin
import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
class HelloPrintPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpPlugin {
private var context: PluginContext? = null
override fun id(): String = "hello-print"
override fun version(): String = "0.1.0"
override fun start(context: PluginContext) {
this.context = context
context.logger.info("hello-print plug-in starting")
// Register HTTP handlers, subscribe to events, etc., here.
}
override fun stop() {
context?.logger?.info("hello-print plug-in stopping")
context = null
}
}
The host's lifecycle order is load → lint → migrate → metadata → start. By the time your start(context) runs:
- PF4J has loaded your JAR.
- The plug-in linter has scanned your bytecode for forbidden imports.
- The host has applied your
META-INF/vibe-erp/db/changelog.xmlagainst the shared Postgres datasource. - The host has loaded your
META-INF/vibe-erp/metadata/*.ymlfiles. - A real
PluginContextis wired withlogger,endpoints,eventBus, andjdbcaccessors.
3. The plug-in JAR layout
src/main/
├── kotlin/
│ └── com/example/helloprint/
│ └── HelloPrintPlugin.kt
└── resources/
├── plugin.yml ← human-readable manifest
└── META-INF/
└── vibe-erp/
├── db/
│ └── changelog.xml ← Liquibase, applied at start
└── metadata/
└── hello-print.yml ← entities, perms, menus
Two manifest files:
-
META-INF/MANIFEST.MFis what PF4J reads to discover the plug-in (set via Gradle'stasks.jar.manifest { attributes(...) }). -
plugin.ymlis human-readable metadata used by the SPA and the operator console. It's parsed byorg.vibeerp.api.v1.plugin.PluginManifest.
A minimal plugin.yml:
id: hello-print
version: 0.1.0
requiresApi: "1.x"
name:
en-US: Hello Print
zh-CN: 你好印刷
permissions:
- hello.print.greet
dependencies: []
requiresApi: "1.x" means "any 1.y.z release of api.v1". A mismatch fails the plug-in at install time, never at runtime.
4. Own your own database tables
Plug-ins use the same Liquibase that the host uses, applied via PluginLiquibaseRunner against the shared host datasource. The host looks for the changelog at exactly META-INF/vibe-erp/db/changelog.xml. The unique META-INF/vibe-erp/db/ path matters: if you put your changelog at db/changelog/master.xml (the host's path), the parent-first classloader will find both files and Liquibase will throw ChangeLogParseException at install time.
Convention: every table you create is prefixed plugin_<your-id>__. The host doesn't enforce this in v0.6, but a future linter will, and the prefix is the only thing that lets a future "uninstall plug-in" cleanly drop your tables.
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="hello-print-001" author="hello-print">
<sql>
CREATE TABLE plugin_helloprint__greeting (
id uuid PRIMARY KEY,
name varchar(128) NOT NULL,
greeting text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX plugin_helloprint__greeting_name_uk
ON plugin_helloprint__greeting (name);
</sql>
<rollback>
DROP TABLE plugin_helloprint__greeting;
</rollback>
</changeSet>
</databaseChangeLog>
5. Register HTTP endpoints
Plug-ins do NOT use Spring annotations. The host exposes a lambda-based registrar through context.endpoints that mounts your handlers under /api/v1/plugins/<plugin-id>/<path>. The handler receives a PluginRequest (path parameters, query parameters, raw body string) and returns a PluginResponse (status + body, serialized as JSON by the host).
Inside your start(context):
override fun start(context: PluginContext) {
this.context = context
context.logger.info("hello-print plug-in starting")
// GET /api/v1/plugins/hello-print/ping
context.endpoints.register(HttpMethod.GET, "/ping") { _: PluginRequest ->
PluginResponse(
status = 200,
body = mapOf("plugin" to "hello-print", "ok" to true),
)
}
// GET /api/v1/plugins/hello-print/greetings
context.endpoints.register(HttpMethod.GET, "/greetings") { _ ->
val rows = context.jdbc.query(
"SELECT id, name, greeting FROM plugin_helloprint__greeting ORDER BY name",
) { row ->
mapOf(
"id" to row.uuid("id").toString(),
"name" to row.string("name"),
"greeting" to row.string("greeting"),
)
}
PluginResponse(body = rows)
}
// POST /api/v1/plugins/hello-print/greetings
context.endpoints.register(HttpMethod.POST, "/greetings") { request ->
// Plug-ins parse JSON themselves. The reference plug-in uses the
// host's Jackson via reflection; a real plug-in author would
// ship their own JSON library or use kotlinx.serialization.
val body = parseJsonObject(request.body.orEmpty())
val name = body["name"] as? String
?: return@register PluginResponse(400, mapOf("detail" to "name is required"))
val id = java.util.UUID.randomUUID()
context.jdbc.update(
"""
INSERT INTO plugin_helloprint__greeting (id, name, greeting)
VALUES (:id, :name, :greeting)
""".trimIndent(),
mapOf(
"id" to id,
"name" to name,
"greeting" to "hello, $name",
),
)
PluginResponse(
status = 201,
body = mapOf("id" to id.toString(), "name" to name),
)
}
// GET /api/v1/plugins/hello-print/greetings/{name} — path variable
context.endpoints.register(HttpMethod.GET, "/greetings/{name}") { request ->
val name = request.pathParameters["name"]
?: return@register PluginResponse(400, mapOf("detail" to "name is required"))
val row = context.jdbc.queryForObject(
"SELECT name, greeting FROM plugin_helloprint__greeting WHERE name = :name",
mapOf("name" to name),
) { r -> mapOf("name" to r.string("name"), "greeting" to r.string("greeting")) }
if (row == null) {
PluginResponse(404, mapOf("detail" to "no greeting for '$name'"))
} else {
PluginResponse(body = row)
}
}
}
A few things this is doing on purpose:
-
Path templates with
{var}placeholders. Spring'sAntPathMatcherextracts the values intoPluginRequest.pathParameters. -
context.jdbcis the api.v1 typed-SQL surface. It wraps Spring'sNamedParameterJdbcTemplatebehind a small interface that hides every JDBC and Spring type. Plug-ins use named parameters only — positional?is not supported. -
PluginResponseis serialized as JSON by the host. AMap<String, Any?>round-trips through Jackson without you doing anything. -
Spring Security still applies. Every plug-in endpoint inherits the
anyRequest().authenticated()rule, so callers need a valid Bearer token. The public allowlist (actuator health,/api/v1/_meta/**,/api/v1/auth/login,/api/v1/auth/refresh) does NOT include plug-in endpoints.
6. Ship metadata about your plug-in
Drop a YAML file at META-INF/vibe-erp/metadata/hello-print.yml:
entities:
- name: Greeting
pbc: hello-print
table: plugin_helloprint__greeting
description: A personalized greeting record
permissions:
- key: hello.print.greet.read
description: Read greetings
- key: hello.print.greet.create
description: Create greetings
menus:
- path: /plugins/hello-print/greetings
label: Greetings
icon: message-circle
section: Hello Print
order: 100
The host's MetadataLoader will pick up this file at plug-in start, tag every row with source = 'plugin:hello-print', and expose it at GET /api/v1/_meta/metadata. The future SPA reads that endpoint to discover what entities and menus exist.
7. Building, deploying, loading
Build the JAR and drop it into the host's plug-in directory:
./gradlew jar
cp build/libs/hello-print-0.1.0.jar /opt/vibe-erp/plugins/
docker restart vibe-erp
For local development against this repo, the reference plug-in uses an installToDev Gradle task that stages the JAR into ./plugins-dev/ so :distribution:bootRun finds it on boot. The same pattern works for any plug-in.
Hot reload of plug-ins without a restart is not in v1.0 — it is on the v1.2+ roadmap.
8. What you'll see in the boot log
A successfully-loading plug-in produces a sequence like this:
PF4J: Plugin 'hello-print@0.1.0' resolved
PF4J: Start plugin 'hello-print@0.1.0'
PluginLiquibaseRunner: plug-in 'hello-print' has META-INF/vibe-erp/db/changelog.xml — running plug-in Liquibase migrations
liquibase.changelog: ChangeSet META-INF/vibe-erp/db/changelog.xml::hello-print-001::hello-print ran successfully
PluginLiquibaseRunner: plug-in 'hello-print' Liquibase migrations applied successfully
MetadataLoader: source='plugin:hello-print' loaded 1 entities, 2 permissions, 1 menus from 1 file(s)
VibeErpPluginManager: vibe_erp plug-in loaded: id=hello-print version=0.1.0 state=STARTED
plugin.hello-print: hello-print plug-in starting
VibeErpPluginManager: vibe_erp plug-in 'hello-print' started successfully
If anything goes wrong, the host logs the per-plug-in failure loudly and continues with the other plug-ins. A failed lint, a failed migration, or a failed start() does NOT bring the framework down.
9. Where to find logs and errors
-
Boot log:
/opt/vibe-erp/logs/vibe-erp.log(or stdout forbootRun). Filter ono.v.p.pluginsfor the plug-in lifecycle,o.v.p.p.lintfor the linter,o.v.p.p.migrationfor Liquibase,o.v.platform.metadatafor the metadata loader, andplugin.<your-id>for your own messages routed throughcontext.logger. - Linter rejections print every offending class + the forbidden type referenced. Fix the import; never work around the linter — it will catch you again.
- Migration failures abort the plug-in's start step but don't stop the framework. Read the Liquibase output carefully.
-
Runtime errors inside your handler lambdas are caught by the dispatcher, logged with a full stack trace, and returned to the caller as a 500 with a generic body. Use
context.logger(not your own SLF4J binding) so your messages get the per-plug-in tag.
10. The reference printing-shop plug-in is your worked example
reference-customer/plugin-printing-shop/ is a real, CI-tested plug-in. When this guide leaves a question unanswered, the answer is almost always "look at how plugin-printing-shop does it":
- The shape of
plugin.ymland theMETA-INF/MANIFEST.MFattributes. - A real Liquibase changelog with multiple changesets at
META-INF/vibe-erp/db/changelog.xml. - A real metadata YAML at
META-INF/vibe-erp/metadata/printing-shop.yml. - Seven HTTP endpoints, including path-variable extraction and POST handlers that parse JSON bodies.
- The dual-supertype trick (
Pf4jPlugin+VibeErpPlugin) with import aliases. - A working
installToDevGradle task for local development.
The plug-in is built by the main Gradle build but not loaded by default. Drop its JAR into ./plugins/ (or use installToDev for development against this repo) to load it.
What this guide does not yet cover
-
Subscribing to events.
context.eventBus.subscribe(...)is wired and live, but no worked example yet — seeEventBusImplandEventBusin api.v1 for the contract. -
Workflow handlers.
TaskHandleris in api.v1 but Flowable isn't wired yet (P2.1, deferred). Plug-ins that try to register handlers today have nothing to call them. -
Translator.
context.translatorcurrently throwsUnsupportedOperationExceptionuntil P1.6 lands. -
Custom permission enforcement. Your declared permissions are stored in
metadata__permissionbut@RequirePermission-style checks aren't enforced yet (P4.3). - Publishing a plug-in to a marketplace — v2 deliverable.
- Hot reload without restart — v1.2+.
- Calling the MCP endpoint as an AI agent — v1.1.
For everything else, the source of truth is ../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md, and the live status of every feature is in ../../PROGRESS.md.