Step-by-step instructions for authoring, testing, and deploying a NodeRouter Mini-App. Read the Rules & Contracts section first — violating any rule causes silent breakage that affects every app loaded after yours.
Skip the manual steps. Copy the prompt below into any AI
coding agent (GitHub Copilot Agent, Claude, Cursor, etc.),
fill in your app description at the bottom, and the agent will
generate all five files — manifest.json,
main.py, index.html,
index.js, and style.css — fully
compliant with every rule in this guide.
Click “Copy Prompt” below and paste it into your AI agent chat.
Complete the APP DESCRIPTION block at the bottom of the prompt with your app details.
The agent generates every file correctly scoped, prefixed, and structured.
ZIP the output and upload via the admin panel. Done.
The prompt is self-contained — it encodes every mandatory rule from this guide so you don’t have to repeat them. After the agent responds, run through the Full Checklist to verify before deploying.
When an app is opened through the admin UI
(/apps/<app_name>/), its
index.html body content is
injected directly into the admin layout — the
same page that runs bootstrap.js and
admin-ws.js.
| Rule | Why |
|---|---|
Wrap all JS in an IIFE(function(){ "use strict"; … })();
|
Prevents every function and variable from leaking
into window and colliding with other
apps or the admin layout.
|
Never reference window.X without a
namespace
|
Generic names like el,
esc, showError will be
overwritten by the next app that uses the same
names.
|
Use event delegation instead of
onclick="globalFn()"
|
onclick attribute strings execute in
global scope; functions inside an IIFE are not
reachable. Use data-action attributes
and a delegated listener.
|
Scope querySelectorAll to
.nr-app
|
The shell wraps your content in
<div class="nr-app">. Always
scope:
document.querySelector(".nr-app").querySelectorAll(…). Otherwise layout elements with the same attribute
are accidentally captured.
|
Do not call AdminWS.* at module scope
|
admin-ws.js loads after your
script. Put any AdminWS usage inside
event listeners or async callbacks, never at top
level.
|
| Rule | Why |
|---|---|
|
Prefix every class with your app slug e.g. hw-, calc-,
sales-
|
Unprefixed names like .btn-xs collide
with any other app that defines the same class. The
last deployed app wins silently.
|
Use Bootstrap CSS variables for all colorsvar(--bs-body-bg), var(--bs-secondary-bg)
|
Hardcoded hex values ignore the system's light/dark theme — your cards render white on a near-black background in dark mode. |
Do not write rules targeting body,
:root, *, or bare HTML
elements
|
These selectors are global and override the admin layout's base styles for every element on the page. |
| Rule | Why |
|---|---|
Do not include
<script
src="bootstrap.bundle.min.js">
in index.html body
|
The shell strips it automatically, but leaving it in is confusing. The layout owns Bootstrap — apps must not bundle it. |
Do not include
<link href="bootstrap.min.css">
inside <body>
|
Layout loads it in <head>. A
second load in body is a no-op but adds parse
overhead. Keep it in <head> only
for standalone testing.
|
✅ All JS inside one IIFE with "use strict"
✅ All CSS classes prefixed with the app slug
✅ All colors use var(--bs-*) tokens
✅ querySelectorAll scoped to document.querySelector(".nr-app")
✅ No onclick="globalFunction()" — use data-action + event delegation
✅ No <script src="bootstrap…"> in the body
✅ Vendor libraries loaded from /assets/vendor/* — never from an external CDN
Go Core embeds these pre-approved libraries and serves them locally from the binary. Never load them from an external CDN — the platform runs in air-gapped enterprise environments with no internet access.
Charts — bar, line, pie, gauge, heatmap, tree. Apache 2.0.
Sortable, filterable, editable data grids. MIT.
Export data to .xlsx /
.csv files in the browser. Apache 2.0.
Parse CSV uploads or CSV strings from the server. MIT.
Lightweight date formatting and manipulation. MIT.
SVG icon set — replaces individual inline SVG blocks. ISC.
<script> tags into every app.
<script> tags in
<head> (or at the bottom of
<body> before index.js) so
globals are available when your IIFE runs.
<head> to
render correctly.
echarts, Tabulator,
XLSX, Papa, dayjs,
lucide) are available on
window — reference them directly inside your
IIFE; do not re-declare them as const.
Required files are marked (R). Everything else is optional.
app_<your_name>/ ← dir name = URL segment = app_name in manifest
│
├── manifest.json (R) ← app identity, version, declared actions
├── main.py (R) ← Python backend — must export execute(data, conn=None)
│
├── index.html (R*) ← frontend page (Bootstrap, no build tools)
├── index.js ← frontend logic — must be wrapped in an IIFE
├── style.css ← component-scoped styles — all classes must be prefixed
│
└── libs/ ← vendored Python packages (pip install --target)
└── <package>/
The zip must contain the contents of the app directory directly at the root — not a wrapping folder.
app_<your_name>.zip
├── manifest.json
├── main.py
├── index.html
├── index.js
├── style.css
└── libs/
└── …
# Pack with (PowerShell):
Compress-Archive -Path app_<your_name>\* -DestinationPath app_<your_name>.zip
manifest.json is the only file Go Core reads
before the runner loads the app.
{
"app_name": "app_<your_name>",
"display_name": "Human-Readable Title",
"version": "1.0.0",
"description": "Shown in the admin UI app list.",
"required_permission": "sales:access", // app-level: guards execution
"actions": [
{
"name": "deal_closed",
"display_name": "Deal Closed",
"description": "Fired when a deal is marked closed-won.",
"required_permission": "sales:access" // action-level: guards subscription
},
{
"name": "report_ready",
"display_name": "Report Ready",
"description": "Fired when a scheduled export finishes.",
"required_permission": "reports:read"
},
{
"name": "open_event",
"display_name": "Open Event",
"description": "Any authenticated user can subscribe."
}
]
}
| Field | Required | Rules |
|---|---|---|
app_name |
Yes |
Must match the directory name exactly. Pattern:
^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ —
start with alphanumeric, max 64 chars, no spaces.
(Go Core enforces 64 chars; the runner accepts up to
128 — always use the stricter limit.)
|
display_name |
No | Human-readable title shown in the admin UI. Free text; keep under 40 chars. |
version |
Yes |
Semver string (MAJOR.MINOR.PATCH).
|
description |
No | Plain-text description shown in the admin app list. Markdown is not rendered. |
required_permission |
No |
App-level execution gate. RBAC
permission slug (e.g. sales:access).
Go Core checks it before forwarding any sync or
async request to the runner — returns HTTP 403 if
the caller lacks it. Omit to allow any authenticated
user to execute the app.
|
actions[] |
No |
Array of action descriptors. Each entry must have
name (slug) and
display_name. Declarative metadata only
— the runner does not validate usage in
main.py.
|
actions[].required_permission |
No |
Action-level subscription gate.
RBAC permission slug. Go Core checks it when a user
calls POST /api/notify/subscribe —
returns HTTP 403 if the caller lacks it. Independent
of the app-level required_permission.
Omit to allow any authenticated user to subscribe to
this action's notifications.
|
Create a directory named after your app, then add the five
starter files below. Replace every
<slug> with your app name (e.g.
app_sales_report) and
<prefix> with your CSS prefix (e.g.
sr).
^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ — start with
alphanumeric, max 64 chars, no spaces. Go
Core rejects names longer than 64 chars at deploy time.
{
"app_name": "<slug>",
"display_name": "Human-Readable Title",
"version": "1.0.0",
"description": "One-line description shown in the admin UI.",
"required_permission": "", // app-level: guards execution (sync + async)
"actions": [
// {
// "name": "my_event",
// "display_name": "My Event",
// "description": "Fired when ...",
// "required_permission": "" // action-level: guards subscription (empty = open)
// }
]
}
required_permission (top-level) gates execution — Go Core returns HTTP 403 on
POST /api/sync/… and POST /api/async/… if the caller lacks it.actions[].required_permission gates subscription — Go Core returns HTTP 403 on
POST /api/notify/subscribe if the caller lacks it.import os, contextlib, psycopg2
# ── DB helper ─────────────────────────────────────────────────────────────
@contextlib.contextmanager
def _get_conn(conn=None):
if conn is not None:
yield conn
else:
with psycopg2.connect(os.getenv("DATABASE_URL")) as own:
yield own
# ── Entry point ───────────────────────────────────────────────────────────
def execute(data: dict, conn=None) -> dict:
action = str(data.get("action", "")).strip()
if action == "my_action":
return _my_action(data, conn=conn)
return {"message": "ok"}
# ── Handlers ──────────────────────────────────────────────────────────────
def _my_action(data: dict, conn=None) -> dict:
return {"message": "hello from my_action"}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><slug></title>
<!-- Bootstrap — for standalone testing only; ignored by admin layout -->
<link rel="stylesheet" href="/static/css/bootstrap.min.css" />
<!-- Uncomment if using Tabulator: -->
<!-- <link rel="stylesheet" href="/assets/vendor/tabulator.min.css" /> -->
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container py-4">
<div class="mb-4">
<h1 class="h3 mb-1">Human-Readable Title</h1>
<p class="text-muted mb-0 small">Short description.</p>
</div>
<!-- Your content here -->
</div>
<!-- Stripped by admin shell; kept for standalone testing: -->
<script src="/static/js/bootstrap.bundle.min.js"></script>
<!-- Uncomment the vendor libs your app needs: -->
<!-- <script src="/assets/vendor/echarts.min.js"></script> -->
<!-- <script src="/assets/vendor/tabulator.min.js"></script> -->
<!-- <script src="/assets/vendor/xlsx.full.min.js"></script> -->
<!-- <script src="/assets/vendor/papaparse.min.js"></script> -->
<!-- <script src="/assets/vendor/dayjs.min.js"></script> -->
<!-- <script src="/assets/vendor/lucide.min.js"></script> -->
<script src="index.js"></script>
</body>
</html>
(function () {
"use strict";
// Scope all DOM queries to .nr-app (admin layout wrapper).
// Falls back to document.body when opened standalone for testing.
var root = document.querySelector(".nr-app") || document.body;
// ── Utilities ──────────────────────────────────────────────────────────
function el(id) { return document.getElementById(id); }
function esc(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
async function syncCall(body) {
const res = await fetch("/api/sync/<slug>", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch (_) { throw new Error(text); }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
// ── Your handlers here ─────────────────────────────────────────────────
})();
/*
* <slug>/style.css
* All classes are prefixed <prefix>- to avoid collisions with other apps.
* All colors use Bootstrap CSS variables (var(--bs-*)) for light/dark theme support.
*/
/* Example — prefix every rule with your app slug tag */
.<prefix>-card {
background: var(--bs-secondary-bg);
border: 1px solid var(--bs-border-color);
border-radius: 4px;
padding: 1rem;
}
main.py
Every app exposes exactly one function:
def execute(data: dict, conn=None) -> dict.
The runner calls it on the sync channel; declare
conn=None to accept a pooled connection injection
for zero handshake cost.
If your app needs its own tables, create them automatically on deploy using module-level code. The runner executes module-level code on every load and hot-reload — so your schema is always in place before any request arrives.
import os, logging, psycopg2
log = logging.getLogger(__name__)
def _migrate() -> None:
"""
Runs idempotent DDL on every app load / hot-reload.
Called at module level — executes automatically on deploy.
"""
dsn = os.getenv("DATABASE_URL", "")
if not dsn:
log.warning("[<slug>] DATABASE_URL not set — skipping migration")
return
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS <slug>_items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
conn.commit()
log.info("[<slug>] migration ok")
# ── Runs on every deploy / hot-reload ─────────────────────────────────────
_migrate()
CREATE TABLE IF NOT EXISTS,
ADD COLUMN IF NOT EXISTS, etc.
Never add a
create_table action to
execute() or a button to the HTML that calls
it. Table names must be prefixed with your app slug (e.g.
<slug>_items, not items).
Dispatch on data["action"] to support multiple
operations from one app.
def execute(data: dict, conn=None) -> dict:
action = str(data.get("action", "")).strip()
if action == "list":
return _list(conn=conn)
if action == "create":
return _create(data, conn=conn)
if action == "run_report": # long-running → send via async channel
return _run_report(data)
return {"message": "ok"}
Use the runner-injected connection when available (zero handshake cost). Fall back to a fresh connection in async subprocesses.
import contextlib, os, psycopg2
@contextlib.contextmanager
def _get_conn(conn=None):
if conn is not None:
yield conn
else:
with psycopg2.connect(os.getenv("DATABASE_URL")) as own:
yield own
def _list(conn=None) -> dict:
with _get_conn(conn) as c:
with c.cursor() as cur:
cur.execute("SELECT id, name FROM <slug>_items ORDER BY id DESC")
rows = cur.fetchall()
return {"rows": [{"id": r[0], "name": r[1]} for r in rows]}
Long-running operations run in an isolated subprocess. Write progress only at 10 % milestones to prevent PostgreSQL MVCC dead-tuple bloat.
import os, time, psycopg2
def _run_report(data: dict) -> dict:
job_id = data.get("_job_id", "") # injected by the runner
dsn = os.getenv("DATABASE_URL", "")
total = 100
milestone = 10
last_reported = 0
for i in range(total):
# --- your heavy compute here ---
time.sleep(0.1)
progress = int(((i + 1) / total) * 100)
if job_id and dsn and progress >= last_reported + milestone:
last_reported = (progress // milestone) * milestone
try:
with psycopg2.connect(dsn) as c:
with c.cursor() as cur:
cur.execute(
"UPDATE noderouter_core.job_queue "
"SET progress = %s, updated_at = NOW() WHERE id = %s",
(last_reported, job_id),
)
c.commit()
except Exception:
pass # never crash the job over a progress write
return {"message": "done", "processed": total}
progress goes 0 →
99 during work; the runner writes 100 on
completion. Never write progress on every iteration — use
fixed milestones (every 10 %).
_actions)
To fire a notification to subscribed users, return a
_actions key alongside your normal result. The
runner strips it before the response reaches the API caller
or the job_queue.result column — subscribers
receive only the per-action payload you specify.
def execute(data: dict, conn=None) -> dict:
deal = close_deal(data["deal_id"])
return {
"deal_id": deal.id, # returned to the API caller as-is
"amount": deal.amount,
"_actions": [ # stripped before the response is sent
{
"name": "deal_closed", # must match a slug declared in manifest actions[]
"payload": {"deal_id": deal.id}, # what subscribers receive — not the full result
}
]
}
| Rule | Detail |
|---|---|
_actions must be a list of dicts |
Plain strings are silently ignored. Each dict must have a mandatory "name" key. |
"name" must match a manifest slug |
Declare the action in manifest.json under actions[] — otherwise it fires but no user can subscribe to it in the Notification UI. |
"payload" is independent of the result |
Delivered to WS subscribers only. Send the minimum data subscribers need — not the full return dict. |
| Works on both sync and async paths |
Sync: runner sends a Tunnel action frame after the res frame — caller is already unblocked.Async: runner fires pg_notify('app_action_triggered', …) after the job row is written.Both paths converge on the same NotifyHub → /api/ws/notify delivery.
|
_actions is stripped automatically |
Never present in the API response body or in job_queue.result. |
/admin/notifications. Only users subscribed to
the app_name + action_name pair receive the WS
event. Subscriptions are managed via
POST /api/notify/subscribe or the toggle in the
Notification UI."required_permission" in the manifest, Go Core
calls HasActionPermission during subscribe and
returns HTTP 403 when the caller lacks the slug. Set it to
the same slug as the app-level permission to keep execution
and subscription access aligned, or use a different slug for
finer-grained control.
Add
<script
src="/assets/vendor/echarts.min.js"></script>
in index.html before index.js. Add
a sized container div in your HTML:
<div id="<prefix>-chart"
style="height:320px"></div>
(function () {
"use strict";
var root = document.querySelector(".nr-app") || document.body;
var chart = echarts.init(root.querySelector("#<prefix>-chart"), null, {
renderer: "canvas",
});
function renderChart(rows) {
chart.setOption({
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: rows.map(function (r) { return r.label; }) },
yAxis: { type: "value" },
series: [{ type: "bar", data: rows.map(function (r) { return r.value; }) }],
});
}
// Re-fit chart when sidebar collapses / window resizes.
window.addEventListener("resize", function () { chart.resize(); });
})();
Add both the CSS link and the JS script. Container:
<div id="<prefix>-grid"></div>
<!-- in <head>: -->
<link rel="stylesheet" href="/assets/vendor/tabulator.min.css" />
<!-- before index.js: -->
<script src="/assets/vendor/tabulator.min.js"></script>
// index.js
(function () {
"use strict";
var root = document.querySelector(".nr-app") || document.body;
var table = new Tabulator(root.querySelector("#<prefix>-grid"), {
layout: "fitColumns",
responsiveLayout: "collapse",
pagination: "local",
paginationSize: 20,
columns: [
{ title: "ID", field: "id", width: 80 },
{ title: "Name", field: "name", widthGrow: 1 },
{ title: "Date", field: "created_at" },
],
});
function loadData(rows) { table.setData(rows); }
})();
(function () {
"use strict";
var root = document.querySelector(".nr-app") || document.body;
var currentRows = [];
function exportToExcel(rows, filename) {
var ws = XLSX.utils.json_to_sheet(rows);
var wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
XLSX.writeFile(wb, (filename || "export") + ".xlsx");
}
// <button data-action="export-excel">Export</button>
root.addEventListener("click", function (e) {
if (e.target.dataset.action === "export-excel") {
exportToExcel(currentRows, "<slug>-report");
}
});
})();
File input:
<input type="file" id="<prefix>-csv-input"
accept=".csv" />
(function () {
"use strict";
var root = document.querySelector(".nr-app") || document.body;
root.querySelector("#<prefix>-csv-input").addEventListener("change", function (e) {
var file = e.target.files[0];
if (!file) return;
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: function (result) {
// result.data is an array of row objects keyed by header name.
console.log(result.data);
},
error: function (err) {
console.error("CSV parse error:", err.message);
},
});
});
})();
(function () {
"use strict";
// Format an ISO timestamp from the server into a readable local string.
function fmtDate(iso) {
return dayjs(iso).format("DD/MM/YYYY HH:mm");
}
// Age in days
function daysAgo(iso) {
return dayjs().diff(dayjs(iso), "day");
}
})();
Add the script, place data-lucide attributes in
your HTML, then call lucide.createIcons() after
every render.
<!-- In HTML: -->
<i data-lucide="download"></i>
<i data-lucide="refresh-cw"></i>
<i data-lucide="alert-triangle"></i>
// index.js
(function () {
"use strict";
var root = document.querySelector(".nr-app") || document.body;
// Replace all data-lucide placeholders with inline SVGs.
lucide.createIcons();
function renderRows(rows) {
root.querySelector("#<prefix>-list").innerHTML = rows
.map(function (r) {
return '<li><i data-lucide="circle-dot"></i> ' + esc(r.name) + "</li>";
})
.join("");
lucide.createIcons(); // re-scan after injecting new HTML
}
})();
async function syncCall(action, payload = {}) {
const res = await fetch("/api/sync/app_<your_name>", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...payload }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
async function asyncCall(action, payload = {}) {
const res = await fetch("/api/async/app_<your_name>", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...payload }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data; // { job_id, status: "pending", node: "..." }
}
async function pollJob(jobId, onProgress) {
while (true) {
const res = await fetch(`/api/async/jobs/${jobId}`);
const job = await res.json();
onProgress(job.progress, job.status);
if (job.status === "completed" || job.status === "failed") return job;
await new Promise((r) => setTimeout(r, 2000));
}
}
libs/)
If your app needs a package not in the runner environment,
install it into libs/:
# Install package into the libs/ folder
pip install <package> --target app_<your_name>/libs
# Then at the top of main.py:
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "libs"))
import my_package
# Default action
curl -s -X POST http://localhost:8000/api/sync/app_<your_name> `
-H "Content-Type: application/json" `
-d '{}'
# Named action
curl -s -X POST http://localhost:8000/api/sync/app_<your_name> `
-H "Content-Type: application/json" `
-d '{"action":"list"}'
# Trigger an async job
curl -s -X POST http://localhost:8000/api/async/app_<your_name> `
-H "Content-Type: application/json" `
-d '{"action":"run_report"}'
RUNNER_SECRET not set),
requests to the runner are accepted without HMAC headers. In
production, always route through Go Core — it stamps every
request with the correct signature. If
required_permission is set, test with a JWT
that carries the correct role before deploying.
# PowerShell — zip the contents (not the folder)
Compress-Archive -Path app_<your_name>\* -DestinationPath app_<your_name>.zip
# Mac / Linux
cd app_<your_name> && zip -r ../app_<your_name>.zip .
1. Open /admin/apps → Deploy Bundle → upload app_<your_name>.zip
2. Go Core extracts to {APPS_DIR}/app_<your_name>/
3. Fires NOTIFY app_updated, 'app_<your_name>'
4. Runner hot-reloads the module atomically — no restart needed
curl -X POST http://localhost:3000/admin/api/apps/deploy \
-H "Authorization: Bearer <jwt>" \
-F "file=@app_<your_name>.zip"
# Hot-reload is automatic — no separate reload endpoint exists.
# Every deploy fires pg_notify('app_updated', app_name).
# The runner's asyncpg LISTEN connection picks it up, re-fetches
# code_bytes from PostgreSQL, and calls importlib.reload() atomically.
# No runner restart needed.
#
# To force a reload during development, re-deploy the same ZIP:
curl -X POST http://localhost:3000/admin/api/apps/deploy \
-H "Authorization: Bearer <jwt>" \
-F "file=@app_<your_name>.zip"
app_name in manifest.json
execute(data, conn=None) is defined in
main.py
required_permission set if execution should be
restricted, or omitted for open access
required_permission is set, the RBAC permission
slug exists and is assigned to the correct roles
actions[].required_permission
set to restrict who can subscribe to its notifications
actions[].required_permission is set, the RBAC
slug exists and is assigned to the correct roles
CREATE TABLE IF NOT EXISTS (idempotent DDL) at
module level
data["_job_id"] for progress writes
with psycopg2.connect(…) as conn: — never left
open
"use strict"
var(--bs-*) tokens — no hardcoded hex values
querySelectorAll scoped to
document.querySelector(".nr-app")
onclick="globalFunction()" — use
data-action + event delegation
/api/sync/<app_name> or
/api/async/<app_name> — not directly to
the runner port
403 responses (permission denied)
libs/ and injected via
sys.path.insert
/assets/vendor/* — no external CDN URLs
<script> tags)
lucide.createIcons() called after each dynamic
HTML render (if Lucide is used)
<head> when Tabulator grid is used