Plugin development SDK for Nousviz
Project description
NousViz Plugin SDK
The nousviz-sdk package is the contract between a NousViz plugin and the platform. Plugins that import from nousviz_sdk are portable; plugins that reach into apps.* will break on any release.
See CHANGELOG.md for SDK release history. The SDK version is pinned in pyproject.toml.
SDK is the only contract
Plugins MUST import only from nousviz_sdk. Reaching into apps.* is unsupported and breaks on any core release.
| What you want | SDK path | Don't use |
|---|---|---|
| DB connection (own tables + refs) | from nousviz_sdk import get_pg_conn |
from apps.api.src.db import get_pg_conn |
| Dict-style cursor | from nousviz_sdk import dict_cursor |
from apps.api.src.db import dict_cursor |
| Decrypted credentials | from nousviz_sdk import get_credential |
Reading os.environ directly |
| Plugin settings (read/write) | from nousviz_sdk.settings import get_setting, set_setting |
Direct DB queries on plugin_settings |
| Sync heartbeat / cancel | from nousviz_sdk.jobs import heartbeat, check_cancelled, get_run_id |
Raw job_runs UPDATEs |
| BaseSyncScript | from nousviz_sdk.sync import BaseSyncScript |
Custom sync boilerplate |
| Lifecycle hooks | from nousviz_sdk.hooks import HookContext, HookResult |
— |
| FastAPI router | from nousviz_sdk import router_for_plugin |
fastapi.APIRouter() without prefix |
Plugins that need something not in the SDK: open an issue. Core either exposes it through the SDK or explains why the plugin shouldn't need it.
How the SDK is installed at runtime (v0.9.0)
The SDK is editable-installed into the NousViz API and worker venvs by scripts/setup.sh:
pip install -e ./sdk
That line is in apps/api/requirements.txt. Running setup.sh installs it; the /api/health endpoint reports its availability. If nousviz_sdk isn't importable at API startup, the plugin install endpoint returns HTTP 503 with a remediation message, and every plugin's load_status shows routes_registered: false.
For standalone use (dev against a plugin without running the full stack), install from the SDK directory:
cd /path/to/nousviz
pip install -e ./sdk
Plugins can declare nousviz-sdk>=1.0,<2.0 in their own requirements.txt — the SDK is published to PyPI on every tagged release of NousViz.
Credential delivery (v0.9.0 / P208)
Decrypted credentials never enter subprocess os.environ. The NousViz jobs-worker runs a Unix socket credential broker. Each subprocess is spawned with a one-shot authentication token; on first get_credential() call, the SDK exchanges the token for the plugin's decrypted credentials, caches them in memory for the subprocess's lifetime, and moves on.
- The token is worthless after one use (cached response, subsequent calls hit memory)
- If a plugin tries to re-use a token, or another plugin tries to present it, the broker returns
DENIED - Without the broker env (
NOUSVIZ_CREDS_SOCKET/NOUSVIZ_CREDS_TOKEN/NOUSVIZ_PLUGIN_ID),get_credential()raisesCredentialBrokerUnavailablewith a clear message
What's in subprocess env:
- Non-secret connection fields (host, port, database name, etc.)
NOUSVIZ_PLUGIN_ID,NOUSVIZ_JOB_RUN_ID(identity/context)NOUSVIZ_CREDS_SOCKET/NOUSVIZ_CREDS_TOKEN(single-use, expires 30s)POSTGRES_HOST,POSTGRES_PORT,POSTGRES_DB(not secret)- Standard system vars (PATH, HOME, LANG)
What's NOT in subprocess env:
- Plugin credentials (password, api_token, PEM, etc.) — fetched via broker
NOUSVIZ_ENCRYPTION_KEY— stays in the API/worker processes onlyPOSTGRES_PASSWORD— core's high-privilege role passwordNOUSVIZ_PLUGIN_PASSWORD— delivered via broker's__db__key on demandGITHUB_TOKENand other operator secrets
Database privileges (v0.9.0 / P203)
Plugin subprocesses connect to Postgres as a dedicated nousviz_plugin role (migration 047), not as the high-privilege nousviz role. The nousviz_plugin role has:
| Table / operation | Plugin access |
|---|---|
| Own plugin's declared tables | full CRUD (granted at install time) |
schema_migrations, plugin_registry, plugin_modules |
SELECT only |
job_runs |
INSERT, SELECT, UPDATE (for heartbeats) |
app_logs |
INSERT only |
plugin_settings |
SELECT, INSERT, UPDATE, DELETE |
credentials, credential_audit_log |
NO ACCESS |
users, user_accounts, user_sessions |
NO ACCESS |
api_keys, deploy_keys, plugin_audit_log |
NO ACCESS |
| Other plugins' tables | NO ACCESS |
A plugin that tries SELECT * FROM credentials gets permission denied for table credentials from Postgres. This is the OS-level firewall. Plugin authors get credentials via get_credential() (broker) and DB access via get_pg_conn() (scoped role).
If you hit a permission denied you didn't expect: re-check that your plugin declares the table in databases.postgres.tables, and that you're querying your own namespace. If your legitimate use case isn't covered by the matrix, open an issue.
Quick start
from nousviz_sdk import get_pg_conn, router_for_plugin, get_credential
router = router_for_plugin("my-plugin")
@router.get("/data")
def get_data():
api_key = get_credential("my-plugin", "api_key")
with get_pg_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT * FROM my_plugin_items LIMIT 10")
return {"rows": cur.fetchall()}
Put this in plugins/installed/my-plugin/api/routes.py and restart the API. The plugin loader picks it up automatically.
Sync / background jobs
from nousviz_sdk.sync import BaseSyncScript
from nousviz_sdk import get_pg_conn, get_credential
class MySync(BaseSyncScript):
def run(self, since=None):
api_key = get_credential("my-plugin", "api_key")
# ... fetch data, insert rows ...
self._rows_synced = 42
if __name__ == "__main__":
MySync().main()
The jobs-worker spawns this script with a credential broker token and monitors heartbeats. Use self.check_cancelled() in long loops to respect operator-initiated cancels.
Hooks
# plugin.yaml
hooks:
on_credentials_saved: hooks.lifecycle:on_saved
# hooks/lifecycle.py (at plugin root, NOT in src/)
from nousviz_sdk.hooks import HookContext, HookResult
def on_saved(ctx: HookContext) -> HookResult:
# ctx.plugin_id, ctx.hook_name, ctx.payload
# Credentials are fetched via get_credential() — not in ctx.
return HookResult(ok=True, message="Credentials acknowledged")
Hooks run in a jobs-worker subprocess with the same broker-delivered credential flow as syncs. Failing hooks show in /system/logs under source=hook_runner when the module itself fails to import (wrong directory, typo) — you don't need to ssh to diagnose.
Local development & testing (v0.9.2 / B138)
The credential broker makes plugin code that calls get_credential()
or get_pg_conn() impossible to run outside a NousViz-spawned subprocess
— there's no broker socket and no token. To write pytest tests against
your plugin, use the dev harness:
from nousviz_sdk.testing import use_test_credentials
def test_my_plugin_sync():
with use_test_credentials({
"host": "localhost",
"port": 5432,
"database": "test",
"username": "test",
"password": "test",
}):
from my_plugin.src import sync
result = sync.run()
assert result.ok
use_test_credentials() registers a stub resolver. Inside the with block:
get_credential(plugin_id, key)returns the matching valueget_pg_conn()works if you supplied a__db__block (or use the default fromfake_db_credentials())- The cache and resolver are reset on exit, so tests don't leak state
reset_sdk_state() is also exported if you need a fresh start without
the context manager (e.g., a pytest fixture finalizer).
What this does NOT do:
- DB:
get_pg_conn()connects to whatever Postgres your env points at. Use a test DB or fixture. - HTTP: pair with FastAPI's
TestClient. - Subprocess paths: this runs in-process. For the real subprocess path (broker token + Unix socket), use NousViz's own integration tests as a reference.
Logging structured events (v0.9.2 / B140)
nousviz_sdk.logging.log_event(level, message, detail=...) lets your
plugin emit structured entries that land in /system/logs with your
plugin's source tag.
from nousviz_sdk.logging import log_event
log_event("error", "Failed to sync customer data",
detail={"customer_id": 12345, "retry_count": 3})
Levels: info, warn, error. In a test harness (no
NOUSVIZ_PLUGIN_ID env), it falls back to stderr — no exception, no
DB write. So your tests don't need to mock the logger.
Frontend components (v0.9.4+)
Plugins can ship custom React widgets in their own repo. Operators consent at install time; NousViz dynamically imports the bundle and registers the component into the same registry the YAML type: custom, component: <Name> panels resolve against.
Manifest
frontend:
components:
- name: MyWidget
path: widget/dist/MyWidget.js
name must be PascalCase. path must be relative, end in .js, and must NOT contain ... The widget-serve endpoint refuses requests for filenames not declared in the manifest.
Build
Plugin author owns the build pipeline. Use --alias:react=/api/widget-runtime/react.js and the matching jsx-runtime alias so your widget imports the host's React at runtime instead of bundling its own:
esbuild widget/MyWidget.tsx \
--bundle \
--format=esm \
--jsx=automatic \
--target=es2020 \
--alias:react=/api/widget-runtime/react.js \
--alias:react/jsx-runtime=/api/widget-runtime/react-jsx-runtime.js \
--external:/api/widget-runtime/react.js \
--external:/api/widget-runtime/react-jsx-runtime.js \
--outfile=widget/dist/MyWidget.js
Both --alias AND --external are required. --alias rewrites the bare specifier ("react" → "/api/widget-runtime/react.js"); --external then prevents esbuild from trying to resolve that resulting URL on disk and instead leaves it as an import statement in the bundle, which the browser resolves at runtime against the host. Without --external, esbuild fails with Could not resolve "/api/widget-runtime/react.js".
The aliases route import { useState } from "react" through host-served shim files at known stable URLs. The shims re-export the host's React copy, so all hooks share the same ReactCurrentDispatcher and your widget's useState/useEffect work correctly.
Bundle size is small (~3-10KB depending on what else you import) because React isn't inlined.
Commit the bundled widget/dist/*.js files to your plugin repo.
Why aliases not bundling (B156)
Earlier guidance (v0.9.4.5) recommended bundling React into each widget. That was wrong. Widgets using hooks crashed with TypeError: Cannot read properties of null (reading 'useState') at first render — the well-known React-18 dual-instance bug, where the widget's bundled React's useState looks up dispatcher state in its own React copy, which is null because the host's React is the one actively rendering.
v0.9.4.7 fixes this by serving a tiny React shim at /api/widget-runtime/react.js (and /react-jsx-runtime.js) that re-exports window.NousViz.React.* — the same React the host uses. Plugins build with --alias:react=<that URL> so their bare import "react" resolves to the shim, which forwards to the host's React. One React copy in play, hooks work.
The aliases are stable URLs maintained by core. They're public-readable (no auth required, since import(url) doesn't carry session tokens). Plugins don't bundle React at all.
Externalising React without these aliases (the historical pre-v0.9.4.7 builds) still fails — import "react" is a bare specifier the browser can't resolve without an importmap. The aliases ARE the alternative to importmap.
Component shape
Each widget receives CustomWidgetProps:
/// <reference path="./nousviz_widget_types.d.ts" />
import { useState, useEffect } from "react";
export default function MyWidget({ pluginId, config }: CustomWidgetProps) {
const [data, setData] = useState<unknown>(null);
useEffect(() => {
window.NousViz.widgets
.apiFetch(`/api/plugins/${pluginId}/some-endpoint`)
.then(r => r.json())
.then(setData);
}, [pluginId]);
return (
<div className="bg-card border border-border rounded-lg p-4">
{JSON.stringify(data)}
</div>
);
}
Copy sdk/widget-types/nousviz_widget_types.d.ts from the NousViz repo into your plugin's repo for the CustomWidgetProps and window.NousViz.widgets types.
Host SDK (window.NousViz.widgets)
Published at runtime by NousViz core before any plugin widget mounts. Stable from v0.9.4.1; semver applies.
| Helper | Use |
|---|---|
apiFetch(input, init?) |
Authenticated fetch (adds session token) |
formatRelativeTime(iso) |
"2 hours ago" / "in 5 minutes" |
formatAbsoluteTime(iso) |
"2026-04-25, 14:00:00 UTC" |
formatNumber(n) |
"1,234,567" |
formatBytes(n) |
"1.4 MB" |
cn(...inputs) |
Tailwind class-name merger |
Other libs (recharts, lucide-react, custom helpers): bundle into your plugin's .js.
Tailwind classes
Plugin widgets share the host's Tailwind config. Common utilities (bg-card, text-foreground, bg-emerald-500/10, etc.) work without setup.
Constraint: Tailwind processes class strings at the host's build time, not at install. Exotic utilities the host has never used may be missing from the host's pre-built CSS. Stick to utilities the host's own widgets use (browse apps/web/src/widgets/), or use inline style={{...}} for arbitrary colors/spacing. The host's tailwind.config.ts does scan plugin widget dirs, so an operator who runs a full setup.sh after installing a plugin will pick up plugin-specific classes.
Operator workflow (one-time per plugin install/update)
- Install or Update plugin via marketplace
- Plugin page banner: "This plugin includes custom frontend code"
- Click Trust this plugin
- Hard-refresh browser
- Custom widgets render
Operators revoke trust via Settings → Plugins → "Revoke trust".
Trust model
Plugin frontend code runs with the same privileges as the host application. Same model as Home Assistant integrations, Grafana plugins, Jenkins plugins. v1.0 will introduce iframe-sandboxed execution for untrusted public-marketplace sources.
Frontend widget failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Widget renders but classes don't style | Utility missing from host's CSS | Use a class the host already uses, or operator rebuilds host frontend |
Cannot read properties of undefined (reading 'apiFetch') |
Widget evaluated before host SDK published | Should not happen; file a bug if it does |
Failed to resolve module specifier "react" |
Build didn't alias react to the host shim |
Rebuild with --alias:react=/api/widget-runtime/react.js --alias:react/jsx-runtime=/api/widget-runtime/react-jsx-runtime.js plus matching --external:/api/widget-runtime/... flags (see Build above) |
Could not resolve "/api/widget-runtime/react.js" (esbuild) |
Used --alias without matching --external |
Add --external:/api/widget-runtime/react.js --external:/api/widget-runtime/react-jsx-runtime.js to the build command |
Cannot read properties of null (reading 'useState') (B156) |
Pre-v0.9.4.7 build that bundled React in (dual-instance hooks bug) | Rebuild with the aliases above; do NOT bundle React |
React is not defined |
Build target too old or --jsx mode wrong |
Use --jsx=automatic --target=es2020 |
| "did not export a default React component" | Missing export default |
End your widget file with export default function ... |
| Banner says trusted but widget doesn't render | Cached old bundle | Hard-refresh (Cmd-Shift-R) |
Failure modes to expect (Python SDK)
| Symptom | Cause | Fix |
|---|---|---|
ModuleNotFoundError: nousviz_sdk |
SDK not installed in the venv | Run pip install -e ./sdk in the API/worker venv. scripts/setup.sh does this. |
CredentialBrokerUnavailable |
Running outside a NousViz subprocess | Use nousviz_sdk.testing.use_test_credentials for local tests. |
CredentialBrokerError: denied |
Token consumed or expired | SDK caches after first fetch — this shouldn't happen in normal plugin code. |
permission denied for table X |
Plugin accessing a table outside its grants | Check your manifest's databases.postgres.tables — add the table if it's yours, or stop accessing it if it's core. |
load_status.routes_registered: false |
Plugin's routes.py failed to import | Check /system/logs filtered by your plugin id. |
Further reading
- Full plugin architecture:
docs/plugin-architecture.md - Security model:
docs/security-model.md - Writing a plugin end-to-end:
docs/contributing-a-plugin.md
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nousviz_sdk-1.0.0.tar.gz.
File metadata
- Download URL: nousviz_sdk-1.0.0.tar.gz
- Upload date:
- Size: 41.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1b78c73c5acc15b15b4fbeb56826ff6fb92398da282ffbfd154f1d935abae277
|
|
| MD5 |
99a581e13ce31fddb43078b96bb758c1
|
|
| BLAKE2b-256 |
dc9842a9c1fe22c1ca87bd813bc034ad12de8a4a9b6b5357ff6bb7491b77137a
|
File details
Details for the file nousviz_sdk-1.0.0-py3-none-any.whl.
File metadata
- Download URL: nousviz_sdk-1.0.0-py3-none-any.whl
- Upload date:
- Size: 43.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
242ee0fe8950877dec29e7e2f106a3b43516ef73075c9a1b2c113b1d36a6cf67
|
|
| MD5 |
f8794531ef9975f15fac7b0d8d52abdf
|
|
| BLAKE2b-256 |
b4554d7f9885ddf74970ebd86df4ac8707882bd615d3705c119b85b347080bad
|