getting-started.md 15.8 KB

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.Plugin so PF4J's loader can instantiate it from the Plugin-Class manifest entry.
  • Implement org.vibeerp.api.v1.plugin.Plugin so vibe_erp's lifecycle hook can call start(context) after PF4J's no-arg start() 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.xml against the shared Postgres datasource.
  • The host has loaded your META-INF/vibe-erp/metadata/*.yml files.
  • A real PluginContext is wired with logger, endpoints, eventBus, and jdbc accessors.

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.MF is what PF4J reads to discover the plug-in (set via Gradle's tasks.jar.manifest { attributes(...) }).
  • plugin.yml is human-readable metadata used by the SPA and the operator console. It's parsed by org.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's AntPathMatcher extracts the values into PluginRequest.pathParameters.
  • context.jdbc is the api.v1 typed-SQL surface. It wraps Spring's NamedParameterJdbcTemplate behind a small interface that hides every JDBC and Spring type. Plug-ins use named parameters only — positional ? is not supported.
  • PluginResponse is serialized as JSON by the host. A Map<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 for bootRun). Filter on o.v.p.plugins for the plug-in lifecycle, o.v.p.p.lint for the linter, o.v.p.p.migration for Liquibase, o.v.platform.metadata for the metadata loader, and plugin.<your-id> for your own messages routed through context.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.yml and the META-INF/MANIFEST.MF attributes.
  • 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 installToDev Gradle 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 — see EventBusImpl and EventBus in api.v1 for the contract.
  • Workflow handlers. TaskHandler is 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.translator currently throws UnsupportedOperationException until P1.6 lands.
  • Custom permission enforcement. Your declared permissions are stored in metadata__permission but @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.