Appearance
Build a Service-mode plugin in Python or Node.js
What you're doing
Every plugin starts life Embedded — Java code loaded in-process via PF4J, sharing the ERP core's own JVM. Since 2026-08-14 a plugin can also declare Service mode: its own standalone process, reached over plain HTTP, that self-registers itself with the platform and sends a heartbeat. Until now the only real Service-mode example was Spring Boot. As of 2026-09-14 the wire contract is proven language-agnostic with two more real, working implementations — the platform genuinely does not care what language your process is written in, as long as it speaks the same three things: self- registration, a heartbeat, and the tenant-context headers.
| Runtime | Framework | Real example in this repo | Port |
|---|---|---|---|
java (default) | spring-boot | backend/modules/hello-plugin-service/ | 8090 |
python | fastapi | backend/modules/hello-plugin-service-python/ | 8091 |
nodejs | express | backend/modules/hello-plugin-service-node/ | 8092 |
This is a different mechanism from Build a tenant extension service (L5) — don't confuse the two:
| This guide (Service-mode plugin) | L5 extension service | |
|---|---|---|
| What it replaces | An entire plugin's REST surface | One narrow extension point (EntityRecordBeforeCreate) |
| Who calls whom | Platform proxies EVERY request for that plugin to your process | Platform calls your process only at one specific hook |
| Auth | Shared-secret self-registration token | Short-lived HMAC-signed bearer token per call |
| Scope | A full business application (HCM, CRM, or your own) | A single tenant-specific customization |
The fastest way: scaffold it
The plugin ids used below (
ai-document-parser,whatsapp-connector) are illustrative example names for this guide, chosen to show the kind of plugin each language suits — a Python service calling an ML model, a Node.js service calling a chat API. Neither ships with this platform; swap in your own plugin id.
Don't hand-write the self-registration/heartbeat/tenant-context wiring — generate a real, runnable starting point:
bash
platform-cli service-plugin:scaffold --id ai-document-parser --runtime python --vendor "Acme Corp" --out .
# or:
platform-cli service-plugin:scaffold --id whatsapp-connector --runtime nodejs --vendor "Acme Corp" --out .This produces the same structure hello-plugin-service-python/ hello-plugin-service-node have by hand: plugin.json, the app source with self-registration/heartbeat/tenant-context already wired in, a requirements.txt/package.json, a multi-stage Dockerfile, and a local dev launcher script. Both arms of this command are proven by actually running their own scaffolded output end-to-end, not just hand-verified once.
The manifest
json
{
"id": "ai-document-parser",
"name": "AI Document Parser",
"version": "1.0.0",
"runtimeModes": ["service"],
"serviceRuntime": "python",
"serviceFramework": "fastapi"
}Python and Node.js plugins can only declare runtimeModes: ["service"] — never "embedded" — nothing outside the JVM can load in-process. Installing a manifest that sets serviceRuntime to "python"/"nodejs" without "service" in runtimeModes fails install with a clear error (PluginCodeLifecycle#validateServiceRuntime). serviceRuntime/ serviceFramework default to "java"/"spring-boot" when absent — every manifest written before this feature existed keeps its exact current behavior.
The wire contract, exactly
Match this regardless of language — it's the same contract hello-plugin-service/-python/-node all implement:
- Self-register on startup:
POST {engine-api base URL}/api/v1/platform/service-registrationswith your plugin id and this process's own reachable URL, headerX-Plugin-Registration-Token: <token>— same env var name in every language,ERP_PLUGIN_SERVICE_TOKEN. - Heartbeat: repeat the same POST on an interval (
deploymentConfig.heartbeatIntervalSeconds, default matches whatever yourplugin.json'sserviceDeploymentdeclares) — a missed heartbeat past a freshness window makes the platform treat your service as unavailable and return a clean503, never a hang. - Deregister on shutdown:
DELETE {engine-api base URL}/api/v1/platform/service-registrations/{pluginId}— a gracefulSIGTERM(container/pod termination, not a hardkill -9) should trigger this. - Every proxied request carries
X-Tenant-Id/X-Actor(andAuthorizationwhen present) — read them, never trust anything else as the tenant identity. - Health endpoint: whatever path your manifest's
serviceDeployment.healthPathdeclares (/healthfor both the Python and Node.js examples — no FastAPI/Express equivalent of Spring's/actuator/healthexists, so this is just a plain declared value, not a framework convention).
Ground yourself first
bash
erp_get_schema {"name":"plugin-manifest"}Build, run, and verify it standalone (no ERP needed yet)
Python:
bash
cd backend/modules/hello-plugin-service-python
python -m venv .venv && .venv/Scripts/activate
pip install -r requirements.txt
uvicorn app.main:app --port 8091
curl localhost:8091/health
curl localhost:8091/greeting -H "X-Tenant-Id: 1" -H "X-Actor: demo"Node.js:
bash
cd backend/modules/hello-plugin-service-node
npm install && npm run build && npm start
curl localhost:8092/health
curl localhost:8092/greeting -H "X-Tenant-Id: 1" -H "X-Actor: demo"Then build the real container each ships:
bash
docker build -t ai-document-parser .
docker run -p 8091:8091 -e ERP_PLUGIN_SERVICE_TOKEN=<your token> ai-document-parserTest it
Both languages have a real, committed, passing test suite you can run and extend as your own starting point — not just illustrative snippets:
bash
# Python — pytest + FastAPI's TestClient, no live engine-api needed
cd backend/modules/hello-plugin-service-python
pip install -r requirements.txt -r requirements-dev.txt
python -m pytest tests/ -v
# Node.js — vitest + supertest, no live engine-api needed
cd backend/modules/hello-plugin-service-node
npm install
npm testBoth suites drive the real app in-process (no port bound, no engine-api required) and cover the same two things worth testing in any Service-mode plugin: your health endpoint responds, and your handlers correctly read X-Tenant-Id/X-Actor from the tenant-context layer. Note Node's Express app is deliberately split into app.ts (routes, importable/testable) and main.ts (the actual listen() + registration + signal handling) — import createApp() in your own tests the same way, rather than importing a file that starts a real server as a side effect of being imported.
Register it against a running ERP
bash
start-hello-plugin-service-python.bat # or start-hello-plugin-service-node.batThen flip the plugin to Service mode in Studio's Plugin Runtime panel — the platform starts routing GET /api/v1/plugins/<your-plugin-id>/** to your process instead of (or in addition to, if you're testing) the Java version.
Deploying it for real
Each example ships a parallel Kustomize tree — deploy/kubernetes/hello-plugin-service-python/ and .../hello-plugin-service-node/, mirroring the Java one's base/overlays/{local,aws} shape with a different image/port/health-path. Copy the tree for your own plugin id rather than trying to parameterize one shared tree across languages — the images, ports, and health checks genuinely differ per language and a single shared tree obscures more than it clarifies.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Install fails with a serviceRuntime error | Declared serviceRuntime: "python"/"nodejs" without "service" in runtimeModes | Embedded is always Java — add "service" to runtimeModes |
403/401 on self-registration | Token mismatch | Same ERP_PLUGIN_SERVICE_TOKEN value on both the ERP core and your process |
| Requests never reach your process | Plugin still on Embedded mode in Studio | Flip the Plugin Runtime toggle to Service |
Clean 503s from every call | No heartbeat received recently | Check your process is actually running and its heartbeat interval hasn't lapsed |
| Graceful shutdown never deregisters | Process was hard-killed (kill -9, or on native Windows a bare terminal kill) rather than sent a real SIGTERM | Use docker stop/pod termination in any real deployment — that delivers a real, trappable signal |
What to read next
- Build a tenant extension service (L5) — the narrower, single-extension-point alternative to this guide
- Publish and upgrade a plugin
platform-cli service-plugin:scaffold --helpfor every scaffold option