Skip to content

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.

RuntimeFrameworkReal example in this repoPort
java (default)spring-bootbackend/modules/hello-plugin-service/8090
pythonfastapibackend/modules/hello-plugin-service-python/8091
nodejsexpressbackend/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 replacesAn entire plugin's REST surfaceOne narrow extension point (EntityRecordBeforeCreate)
Who calls whomPlatform proxies EVERY request for that plugin to your processPlatform calls your process only at one specific hook
AuthShared-secret self-registration tokenShort-lived HMAC-signed bearer token per call
ScopeA 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-registrations with your plugin id and this process's own reachable URL, header X-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 your plugin.json's serviceDeployment declares) — a missed heartbeat past a freshness window makes the platform treat your service as unavailable and return a clean 503, never a hang.
  • Deregister on shutdown: DELETE {engine-api base URL}/api/v1/platform/service-registrations/{pluginId} — a graceful SIGTERM (container/pod termination, not a hard kill -9) should trigger this.
  • Every proxied request carries X-Tenant-Id/X-Actor (and Authorization when present) — read them, never trust anything else as the tenant identity.
  • Health endpoint: whatever path your manifest's serviceDeployment.healthPath declares (/health for both the Python and Node.js examples — no FastAPI/Express equivalent of Spring's /actuator/health exists, 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-parser

Test 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 test

Both 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.bat

Then 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

SymptomCauseFix
Install fails with a serviceRuntime errorDeclared serviceRuntime: "python"/"nodejs" without "service" in runtimeModesEmbedded is always Java — add "service" to runtimeModes
403/401 on self-registrationToken mismatchSame ERP_PLUGIN_SERVICE_TOKEN value on both the ERP core and your process
Requests never reach your processPlugin still on Embedded mode in StudioFlip the Plugin Runtime toggle to Service
Clean 503s from every callNo heartbeat received recentlyCheck your process is actually running and its heartbeat interval hasn't lapsed
Graceful shutdown never deregistersProcess was hard-killed (kill -9, or on native Windows a bare terminal kill) rather than sent a real SIGTERMUse docker stop/pod termination in any real deployment — that delivers a real, trappable signal