Building Plugins
EnterpriseBuild your own NetStacks Controller plugin: the .nspkg package layout, the manifest, the netstacks-plugin scaffold CLI, database migrations, Terminal panels, and the Controller's internal API for plugins.
Overview
A NetStacks plugin is an HTTP service that runs in its own Docker container next to the Controller. You ship it as a .nspkg file; the Controller builds the image, runs the database migrations, starts the container when you enable it, and forwards requests from /api/plugins/<name>/* to the container. The plugin can call back into the Controller through a versioned internal API using a service token the Controller gives it.
The NetStacks Alerts and Incidents plugins are built this way, with Python 3.12 and FastAPI. Any language works as long as the service runs in a container and listens on the port in its manifest.
To install and manage a finished package, see Plugin System.
Package Layout
A .nspkg is a ZIP archive with the .nspkg extension. Only manifest.json at the root is required; the conventional layout is:
my-plugin/
├── manifest.json # required: plugin metadata (see Manifest Reference)
├── migrations/ # optional: *.sql files, applied in version order
│ └── 001_initial_schema.sql
└── service/
├── Dockerfile # the Controller builds the image from this on upload
└── ... # your service source code- If
service/Dockerfileexists, the Controller builds the image on upload and tags it with the manifest'simage. If the Dockerfile usesCOPY service/...paths, the build context is the package root; otherwise it is theservice/folder. - The package can be up to 100 MB.
- Paths must be relative — entries containing
..or starting with/are rejected.
The netstacks-plugin CLI
The netstacks-plugin command-line tool creates a new plugin project, checks it and packages it. It has three commands:
| Command | What it does |
|---|---|
netstacks-plugin new <name> | Creates <name>/ from the Python FastAPI template. Asks for a display name, description, author, the capabilities you need, whether the plugin needs a database, and the port (default 8080). Options: -o, --output-dir <DIR> (default .), -t, --template python (Python is the only template). |
netstacks-plugin validate [path] | Checks the project in path (default .): manifest.json exists and has the required fields, service/Dockerfile and a service entry point exist, the name, version and port are valid, resources sets both limits, and the migrations_dir contains at least one .sql file. |
netstacks-plugin package [path] | Runs validate, then writes <name>-<version>.nspkg (or the file given with -o, --output <FILE>). It leaves out caches, virtual environments, .git, node_modules, target, .env and editor folders. |
A typical session:
netstacks-plugin new config-auditor
cd config-auditor
# ...write your service code and migrations...
netstacks-plugin validate
netstacks-plugin packagePlugin names for the CLI use lowercase letters and hyphens, are 3 to 50 characters long, start with a letter, and can't be core, internal, admin, api, system or plugin.
The template generates a manifest.json, a multi-stage service/Dockerfile based on python:3.12-slim that runs as a non-root user, a FastAPI app with a GET /health endpoint and an example /admin/items CRUD router, an asyncpg database module, and migrations/001_initial_schema.sql if you chose a database.
python:3.12-slim base image doesn't include curl. Make sure the manifest's health_check.command uses a tool that exists in your image, for example: python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')". Also keep the manifest port equal to the port your service actually listens on (the template's Dockerfile starts uvicorn on 8080).Manifest Reference
The Controller validates manifest.json on upload. A minimal manifest:
{
"name": "config-auditor",
"version": "1.0.0",
"display_name": "Config Auditor",
"description": "Audits device configurations against policy",
"image": "netstacks-plugin-config-auditor:1.0.0",
"port": 8080,
"capabilities_required": ["read_devices"],
"resources": { "cpu_limit": 0.5, "memory_limit": 268435456 },
"health_check": {
"command": "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/health')\""
},
"migrations_dir": "migrations"
}| Field | Required | Rules / meaning |
|---|---|---|
name | yes | Lowercase, starts with a letter, letters/digits/hyphens, 3–50 characters. Becomes the container name netstacks-plugin-<name>, the API prefix /api/plugins/<name> and the database schema plugin_<name> (hyphens become underscores). |
version | yes | MAJOR.MINOR.PATCH, for example 1.0.0. |
display_name | yes | 1–100 characters. Shown in the admin console. |
description | yes | Up to 500 characters. |
image | yes | Docker image tag used to build and run the container. |
port | yes | 1024–65535. The port your service listens on inside the container. |
capabilities_required | yes | List of capabilities granted to the plugin's service token. See Internal API for the capabilities the Controller checks. "*" is not allowed. |
resources.cpu_limit | no | CPU cores, 0.1–4.0. |
resources.memory_limit | no | Bytes, 67108864 (64 MB) to 4294967296 (4 GB). |
health_check.command | if health_check is set | Shell command used as the container's Docker health check. Optional tuning: interval_seconds (5–300, default 30), timeout_seconds (1–60, default 10), retries (0–10, default 3), start_period_seconds (0–300, default 15). |
migrations_dir | no | Folder in the package with .sql migrations. If you leave it out, no migrations run. |
terminal_panels | no | Panels shown in the Terminal activity bar and the admin console sidebar. See Terminal and Admin Panels. |
settings_schema | no | A JSON Schema object. The plugin detail page renders a Settings form from it (enum → dropdown, boolean → checkbox, number/integer → number field, anything else → text field; description becomes help text) and validates values on save. |
openapi_path | no | Path of your service's OpenAPI document (default /openapi.json). The Controller merges it into its own API documentation under /api/plugins/<name>/. |
The manifest schema also accepts ui_bundle, navigation, routes and dashboard_widgets for loading a JavaScript UI bundle from the package's ui/ folder into the admin console. ui_bundle is required when you use routes or dashboard_widgets.
Terminal and Admin Panels
Each entry in terminal_panels becomes an icon in the Terminal activity bar and an entry in the admin console's Plugins sidebar section. The panel shows a table filled from a GET on your plugin's data_endpoint.
"terminal_panels": [
{
"id": "findings",
"label": "Audit Findings",
"icon": "FileCode",
"data_endpoint": "/admin/findings",
"refresh_interval_seconds": 30,
"columns": [
{ "key": "device", "label": "Device" },
{ "key": "rule", "label": "Rule" },
{ "key": "created_at", "label": "Found" }
],
"actions": [
{ "id": "rescan", "label": "Re-scan", "endpoint": "/admin/rescan", "method": "POST" },
{ "id": "dismiss", "label": "Dismiss", "endpoint": "/admin/findings/{id}/dismiss",
"confirm": "Dismiss this finding?", "style": "danger" }
]
}
]| Field | Meaning |
|---|---|
id, label, data_endpoint | Required. The admin console opens the panel at /plugin/<name>/<id>. label is the panel title and Terminal tooltip. |
icon | Bell, Filter, FileCode and Rocket are recognized in both places (the Terminal also knows AlertTriangle and Activity). Other values get a generic plugin icon. |
columns | {key, label} objects. Keys ending in _at are shown as date-times in the admin console. |
actions | Terminal only. Buttons with id, label, endpoint, optional method (default POST), confirm text and style (primary, danger, default). An endpoint containing {id} becomes a per-row action. |
refresh_interval_seconds | How often the panel reloads its data. |
Your data_endpoint receives an org_id query parameter set by the Controller — filter your data by it.
Database Migrations
- Each plugin gets its own PostgreSQL schema,
plugin_<name>(hyphens become underscores). - Migration files are
*.sqlfiles inmigrations_dir. The version is the number before the first underscore (001_initial_schema.sqlis version 1). Files apply in version order, each in its own transaction. - Migrations run on upload and again on every enable; the Controller records applied versions in
plugin_<name>._sqlx_migrations, so each file runs once. When you ship a new version, add new files rather than editing old ones. - Prefix table names with your schema (
CREATE TABLE plugin_config_auditor.findings ...). Don't add foreign keys to core Controller tables.
Runtime Environment
The Controller sets these environment variables in the container:
| Variable | Value |
|---|---|
CONTROLLER_API_URL | Base URL of the Controller API, for internal API calls. |
NETSTACKS_SERVICE_TOKEN | The plugin's service token, carrying the capabilities from capabilities_required. |
DATABASE_URL | PostgreSQL connection string. Use your plugin schema. |
PLUGIN_NAME, PLUGIN_PORT | The manifest name and port. |
Requests forwarded from /api/plugins/<name>/* carry the caller's Authorization header, an X-Plugin-Name header, and — for users — the caller's organization as the org_id query parameter and X-Org-Id header. The Controller replaces any client-supplied values for these two. Forwarded requests time out after 30 seconds.
Internal API for Plugins
Plugins call the Controller at $CONTROLLER_API_URL/internal/v1/... with the header Authorization: Bearer $NETSTACKS_SERVICE_TOKEN. Each endpoint checks for a capability in the token, so list every capability you need in capabilities_required. These are all the internal endpoints:
| Endpoint | Capability | Purpose |
|---|---|---|
GET /devices | read_devices | Device list: {"devices": [{id, name, hostname, device_type, status}]} |
GET /profile-secrets/:id | read_profile_secrets | Decrypted credentials of a credential profile. Every call is audit-logged. |
GET /knowledge/search?q= | search_knowledge | Text search over knowledge base document titles and content. Returns up to 20 {id, title, snippet}. |
POST /knowledge/documents | create_knowledge_document | Add a document: {org_id, title, raw_content, source_type?, category?, tags?} |
POST /llm/chat | invoke_llm | Chat completion through the Controller's configured AI provider: {messages: [{role, content}]} → {content, model}. |
POST /netconf/execute | execute_netconf | {device_id, credential_id, operation: get_config | load_merge, commands?, format?} |
POST /gnmi/execute | execute_gnmi | {device_id, credential_id, operation: get | set, path?, commands?, encoding?} |
GET /api-resources, GET /api-resources/:id | read_api_resources | Read the Controller's configured API resources. |
POST /agents/trigger | trigger_agent | Start AI agent runs for an alert (used by the Alerts plugin). |
POST /plugin-tools/register | any valid token | Register a tool AI agents can call (below). |
POST /execution-webhooks/register | any valid token | Get a callback when agent runs finish (below). |
Example from Python with httpx:
import os, httpx
BASE = os.environ["CONTROLLER_API_URL"] + "/internal/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['NETSTACKS_SERVICE_TOKEN']}"}
async def list_devices():
async with httpx.AsyncClient(verify=False, timeout=30) as client:
r = await client.get(f"{BASE}/devices", headers=HEADERS)
r.raise_for_status()
return r.json()["devices"] /credentials endpoint — list devices with GET /devices and fetch credentials with GET /profile-secrets/:id. Set verify to match how your Controller's certificate is issued.Giving AI Agents New Tools
A plugin can add tools that Controller AI agents can call. Register each tool at startup:
POST /internal/v1/plugin-tools/register
{
"plugin_name": "config-auditor",
"tool_name": "audit_device",
"description": "Run the policy audit on one device and return findings",
"parameters_schema": {
"type": "object",
"properties": { "device_id": { "type": "string" } },
"required": ["device_id"]
},
"execute_url": "/tools/audit-device",
"is_destructive": false
}When an agent uses the tool, the Controller sends a POST to http://netstacks-plugin-<name>:<port><execute_url> with the agent's arguments plus org_id (and trigger_source_id when the run was triggered by something, such as an alert). Your response body is returned to the agent. Optional fields: category (default plugin), required_permission (default plugins.use) and trigger_type — set it (for example alert) to offer the tool only in runs with that trigger; leave it out to offer it in every run.
Agent Execution Callbacks
Register a callback to hear when agent runs of a given trigger type finish:
POST /internal/v1/execution-webhooks/register
{ "plugin_name": "config-auditor", "callback_url": "http://netstacks-plugin-config-auditor:8080/internal/execution-event", "trigger_type": "alert" }When a matching run ends, the Controller POSTs {execution_id, agent_id, agent_name, trigger_source_id, event_type: completed | failed, summary, detail: {state, final_answer, iterations_used, tool_calls_made}} to your callback URL.
Calling Your Plugin's API
Users and scripts reach your plugin at https://<controller>/api/plugins/<name>/<path> with a NetStacks access token (see API Authentication). The plugin must be enabled in the caller's organization. The Controller authenticates the caller; checking what each caller may do inside your plugin is up to your service. The Terminal also sends an X-User-Id header on plugin requests.
Static files in the package's ui/ folder are served at /api/plugins/<name>/ui/*.
Build and Test Checklist
- Run
netstacks-plugin validateand fix every error. - Build the image locally (
docker build) and run it to confirm the health check command succeeds inside the container. - Run
netstacks-plugin package. - Upload the
.nspkgon a test Controller (Administration → Plugins → Upload Plugin), then click Enable. - Open the plugin detail page and confirm Health Status shows
runningandhealthy. - Check your panel in the admin console sidebar and in the Terminal activity bar. If something fails, read
docker logs netstacks-plugin-<name>on the Controller host.
Related Pages
- Plugin System — installing and managing plugins.
- AI Agents — the agents that can use plugin tools.
- Knowledge Base
- API Authentication