App Build Guide

Developer Guide

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.

AI-First

Build with an AI Agent

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.

1
Copy the prompt

Click “Copy Prompt” below and paste it into your AI agent chat.

2
Fill in your app

Complete the APP DESCRIPTION block at the bottom of the prompt with your app details.

3
Get all 5 files

The agent generates every file correctly scoped, prefixed, and structured.

4
Deploy

ZIP the output and upload via the admin panel. Done.

noderouter-app-build.prompt.txt

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.


Mandatory

App Contract

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.

JavaScript Rules
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.
CSS Rules
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 colors
var(--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.
HTML / Bootstrap Rules
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.
Quick Checklist
 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
Libraries

Available Frontend Libraries

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.

Apache ECharts 5
/assets/vendor/echarts.min.js

Charts — bar, line, pie, gauge, heatmap, tree. Apache 2.0.

Tabulator 6
/assets/vendor/tabulator.min.js
/assets/vendor/tabulator.min.css

Sortable, filterable, editable data grids. MIT.

SheetJS Community
/assets/vendor/xlsx.full.min.js

Export data to .xlsx / .csv files in the browser. Apache 2.0.

PapaParse 5
/assets/vendor/papaparse.min.js

Parse CSV uploads or CSV strings from the server. MIT.

Day.js 1
/assets/vendor/dayjs.min.js

Lightweight date formatting and manipulation. MIT.

Lucide Icons
/assets/vendor/lucide.min.js

SVG icon set — replaces individual inline SVG blocks. ISC.

Loading Rules
  • Load only the libraries your app actually uses — do not copy-paste all six <script> tags into every app.
  • Place <script> tags in <head> (or at the bottom of <body> before index.js) so globals are available when your IIFE runs.
  • Tabulator requires its CSS in <head> to render correctly.
  • All globals (echarts, Tabulator, XLSX, Papa, dayjs, lucide) are available on window — reference them directly inside your IIFE; do not re-declare them as const.
Structure

App Folder Structure

Folder Layout

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>/
*index.html is required only if the app has a UI. A pure backend app (called only by other services) can omit all three frontend files.
ZIP Layout

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 Reference

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.
Step 1

Initialize the App Files

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).

Naming rule: The directory name is the app's URL segment. It must match ^[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)
    // }
  ]
}
Two independent permission levels:
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.
Leave either field empty (or omit it) to allow any authenticated user.
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, "&amp;").replace(/</g, "&lt;")
      .replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
  }

  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;
}
Step 3

Write 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.

Database Migration

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()
Rules: Every DDL statement must be idempotent — use 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).
Action Routing

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"}
Sync Database Access

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]}
Async Job with Progress Milestones

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}
Rules: Hard timeout is 300 seconds. progress goes 0 → 99 during work; the runner writes 100 on completion. Never write progress on every iteration — use fixed milestones (every 10 %).
Action Notifications (_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.
Subscription UI: users opt in at /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.

Subscription permission: if an action declares "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.
Step 4

Write the Frontend

Using ECharts (Charts)

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(); });
})();
Using Tabulator (Data Grid)

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); }
})();
Using SheetJS (Excel Export)
(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");
    }
  });
})();
Using PapaParse (CSV Parsing)

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);
      },
    });
  });
})();
Using Day.js (Date Formatting)
(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");
  }
})();
Using Lucide Icons

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
  }
})();
API Calls from the Frontend
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));
  }
}
Step 5

Local Dependencies (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
Step 6

Test Locally

PowerShell
# 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"}'
In development (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.
Step 7

Package and Deploy

# 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"
Reference

Full Deployment Checklist

  • Directory name matches app_name in manifest.json
  • execute(data, conn=None) is defined in main.py
  • App-level required_permission set if execution should be restricted, or omitted for open access
  • If app-level required_permission is set, the RBAC permission slug exists and is assigned to the correct roles
  • Each action that carries sensitive data has actions[].required_permission set to restrict who can subscribe to its notifications
  • If actions[].required_permission is set, the RBAC slug exists and is assigned to the correct roles
  • DB migration uses CREATE TABLE IF NOT EXISTS (idempotent DDL) at module level
  • Table names prefixed with the app slug
  • Async jobs use data["_job_id"] for progress writes
  • Progress writes batched at 10 % milestones — no per-iteration writes
  • DB connections opened with with psycopg2.connect(…) as conn: — never left open
  • All JS inside one IIFE with "use strict"
  • All CSS classes prefixed with the app slug
  • All colors use var(--bs-*) tokens — no hardcoded hex values
  • querySelectorAll scoped to document.querySelector(".nr-app")
  • No onclick="globalFunction()" — use data-action + event delegation
  • Frontend calls go to /api/sync/<app_name> or /api/async/<app_name> — not directly to the runner port
  • Frontend handles 403 responses (permission denied)
  • No build tools, no iframes in the frontend
  • Local deps placed under libs/ and injected via sys.path.insert
  • Vendor libraries loaded from /assets/vendor/* — no external CDN URLs
  • Only vendor libraries actually used are included (no unused <script> tags)
  • lucide.createIcons() called after each dynamic HTML render (if Lucide is used)
  • Tabulator CSS included in <head> when Tabulator grid is used
  • ZIP root contains files directly — no wrapping folder