# 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`](../plugin-api/overview.md) first. For the architectural reasoning behind the constraints, see [`../architecture/overview.md`](../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`): ```kotlin 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: ```kotlin 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`: ```yaml 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___`. 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 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); DROP TABLE plugin_helloprint__greeting; ``` ## 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//`. 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)`: ```kotlin 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` 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`: ```yaml 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: ```bash ./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.` 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`](../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md), and the live status of every feature is in [`../../PROGRESS.md`](../../PROGRESS.md).