Skip to content

Manifest Reference

Every FreeSDN SDK plugin ships a plugin.yaml at the root of its ZIP archive. The runtime parses and validates this file through PluginManifest (backend/app/plugins/schema.py). The published dev SDK mirrors this model in freesdn-sdk/sdk/src/freesdn_sdk/manifest.py, but the two diverge in important ways - see the SDK vs runtime differences section before you ship.


These are the direct fields of the plugin.yaml document.

Field Type Default Required Validation
id string - Yes ^[a-z0-9][a-z0-9\-]{0,98}[a-z0-9]$ - 2-100 chars, lowercase alphanumeric and hyphens only. Must not be a reserved ID.
name string - Yes 1-200 chars.
version string - Yes Strict semver MAJOR.MINOR.PATCH - all numeric, exactly 3 parts. 1.0.0 is valid; 1.0 and 1.0.0-beta are not.
description string "" No Max 2000 chars.
author string "" No Max 200 chars.
license string "MIT" No Free string; not validated.
homepage string or null null No Max 500 chars. Must start with http:// or https:// if provided.
min_core_version string "1.0.0" No Free string. Recorded, not enforced.
entry_point string "plugin.py" No Must match ^[a-zA-Z0-9_/\-]+\.py$. No .., no leading / or \. Resolved relative to the plugin directory at load time.
class_name string - Yes Must be a valid Python identifier and must not start with _. The loader imports entry_point and looks for this class.
permissions list of PluginPermission [] No SDK capability declarations. The confused-deputy guard enforces these at request time.
api_prefix string or null null No Must match ^/[a-z0-9][a-z0-9/_-]*$. No ... Defaults to /{id} at mount time if omitted. Routes are mounted at /api/v1{api_prefix}.
dependencies list of PluginDependency [] No Inter-module dependencies. Declared only, not enforced at install.
nav_items list of PluginNavItem [] No Sidebar navigation entries the UI renders when the plugin is active.
public_routes list of PluginPublicRoute [] No Unauthenticated routes protected by HMAC. Runtime-only - the published dev SDK manifest does not include this field.
event_subscriptions list of string [] No Event-bus patterns the plugin may subscribe to. EventSDK.subscribe() rejects any pattern not declared here. Bare * and # wildcards are rejected.
settings_schema object or null null No JSON Schema describing the settings the plugin stores via PluginSettingsSDK. FreeSDN records it but does not validate settings values against it at runtime.
schedules list of object [] No Methods FreeSDN runs on a timer, per organisation. Max 10. See Scheduling.
python_dependencies list of string [] No Max 50 entries. Must use exact name==version pinning. Loose specifiers (>=, ~=, etc.) are rejected. See dependency pinning.

The id you choose becomes a permanent namespace prefix for everything the plugin owns:

  • REST routes mount at /api/v1/{id} (unless you override api_prefix)
  • All events the plugin emits are namespaced plugin.{id}.{event_type}
  • Automation triggers and actions register as plugin.{id}.{type}
  • AI tools are prefixed plugin_{id}_{name}
  • Device external IDs you register must start with plugin.{id}:
  • Per-org settings are scoped by (plugin_id, organization_id, key)

Choose a stable, descriptive ID. You cannot rename a plugin once installed without uninstalling and reinstalling.

The following IDs are blocked and will cause install to fail:

admin, auth, api, core, system, devices, alerts, users, settings, backup, vpn,
automation, webhooks, ai, collector, integrations, plugins, marketplace, health,
status, metrics, internal

Declares an SDK capability the plugin needs. The runtime enforces these through _require_permission() - if a capability is not declared here, the SDK raises PermissionError when your code tries to use it.

Field Type Default Notes
code string (required) The SDK capability string. See the permission codes table below.
name string (required) Human-readable name shown in the UI.
description string "" Short explanation of why the plugin needs this capability.

The vocabulary is fixed. A code outside this table is rejected when the manifest is parsed, before anything is installed, because a declared permission the platform does not enforce reads to an operator as a boundary that is not there.

Code Caller must hold any of What it grants
devices.read device:read Read device inventory via ctx.devices.list() and ctx.devices.get()
devices.register device:write Create inventory records via ctx.devices.register_device()
devices.stage device:write, device:update, config:push Propose a device configuration change via ctx.devices.stage_change()
sites.read site:read Read sites via ctx.sites.list() and ctx.sites.get()
alerts.read alert:read List alerts via ctx.alerts.list()
metrics.read analytics:read Read time-series via ctx.metrics.query() / .series()
alerts.write alert:create, alert:update Create and resolve alerts via ctx.alerts.create() / ctx.alerts.resolve()

The live vocabulary is served by GET /api/v1/plugins/capabilities, which is also what the install consent dialog renders.


Declares a dependency on a FreeSDN native module.

Field Type Default Notes
module_id string (required) ID of the required module (e.g. network, cameras).
min_version string "1.0.0" Minimum acceptable module version. Not enforced at install.
optional boolean false Whether the plugin degrades gracefully without this module.

Adds an entry to the FreeSDN sidebar when the plugin is active for the current organization.

Field Type Default Notes
path string (required) Route path relative to the plugin’s frontend mount.
label string (required) Display label in the sidebar.
icon string "Package" Lucide icon name.
order integer 50 Sort order among nav items. Lower numbers appear first.

Declares an unauthenticated webhook-style route protected by HMAC-SHA256. The route guard validates the HMAC before your handler runs, and replay protection via Valkey ensures each nonce is accepted only once within a 300-second window.

Field Type Default Notes
path string (required) Must match ^/[a-z0-9][a-z0-9/_-]*$. No ... Trailing slash is stripped.
methods list of string ["POST"] Uppercased. Allowed values: POST, PUT, PATCH, DELETE. GET is intentionally not permitted for public routes. Must be non-empty.

Every request to a public route must include all four of these headers:

Header Contents
X-FreeSDN-Plugin-Org Organization ID (UUID) the request targets
X-FreeSDN-Plugin-Timestamp Unix timestamp (seconds). Must be within 300 seconds of server time.
X-FreeSDN-Plugin-Nonce 16-128 char unique value per request
X-FreeSDN-Plugin-Signature sha256= + HMAC-SHA256(secret, canonical_message)

The canonical message is the newline-joined concatenation of: timestamp, nonce, METHOD, path, query_string, org_id, sha256(body).

Obtain the HMAC secret by calling POST /api/v1/plugins/{plugin_id}/public-auth/rotate-secret (org_admin required). The plaintext secret is returned once and never again.


The python_dependencies list feeds the hash-pinned install pipeline. The runtime applies a strict validator that the dev SDK does not:

Rule Runtime Dev SDK (freesdn-sdk validate)
Specifier format Must be name==version exactly Accepts >=, ~=, !=, etc.
Extras syntax (pkg[extra]) Rejected Rejected
Specs starting with - Rejected Rejected
Specs containing ; (environment markers) Rejected Rejected

If python_dependencies is non-empty, you must also ship a requirements.txt in the ZIP root. The runtime verifies:

  1. The file contains --hash=sha256: annotations for every package (generate with pip-compile --generate-hashes).
  2. Every bare package name in python_dependencies appears in the requirements file.
  3. The runtime opt-in flag PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=true is set (default is false).

Install creates a per-plugin .venv and runs pip install --require-hashes --no-deps --no-cache-dir --only-binary :all: - no sdist code execution, no transitive upgrades, no cache poisoning.


Resource Limit
ZIP archive (compressed) 50 MB
ZIP archive (uncompressed) 200 MB
schedules entries 10
schedules[].interval_seconds 60 to 86400
python_dependencies entries 50
Automation triggers 50 per plugin
Automation actions 50 per plugin
AI tools 20 per plugin
Registered devices 1,000 per plugin
HTTP response (via PluginHTTPClient) 10 MB
HTTP timeout 60 seconds
AI tool result payload 256 KB
Settings blob per PUT /settings 32 KiB

This is the shipped netbox-sync manifest. It is the worked example because it is the archetypal plugin: an integration with one specific third-party product, which is exactly the kind of thing that should never live in the core.

id: netbox-sync
name: NetBox Sync
version: 3.1.0
description: >
Pushes the FreeSDN device inventory into NetBox as devices and sites. Runs
dry by default so you can see exactly what would change before anything does,
and reports conflicts rather than overwriting them.
author: FreeSDN
license: MIT
homepage: https://registry.freesdn.org/p/netbox-sync
min_core_version: 26.06.1
entry_point: plugin.py
class_name: NetBoxSyncPlugin
permissions:
- code: devices.read
name: Read Devices
description: Read the inventory that will be pushed to NetBox
- code: sites.read
name: Read Sites
description: Map FreeSDN sites onto NetBox sites
- code: alerts.read
name: Read Alerts
description: Avoid raising a duplicate alert for a sync that is already failing
- code: alerts.write
name: Write Alerts
description: Raise an alert when a sync fails or conflicts
event_subscriptions:
- device.adopted
- device.updated
api_prefix: /netbox-sync
nav_items:
- path: /netbox-sync
label: NetBox Sync
icon: DatabaseZap
order: 84
settings_schema:
type: object
properties:
enabled:
type: boolean
default: true
description: Master on/off switch
netbox_url:
type: string
default: ""
description: NetBox base URL, e.g. https://netbox.example.com
netbox_token:
type: string
secret: true # encrypted on write, never returned on read
default: ""
description: NetBox API token with write access to DCIM
dry_run:
type: boolean
default: true
description: Report what WOULD change without changing it
python_dependencies: []
schedules:
- name: inventory_sync
method: scheduled_sync
interval_seconds: 3600
enabled_by_default: false
description: >-
Reconcile FreeSDN's inventory against NetBox. Ships DISABLED: an
unattended write into an external system of record should be a
deliberate decision.

Three things in there are worth copying rather than skimming past:

  • secret: true on the token. Without it the credential is stored as plaintext in plugin_settings and returned on read, visible to anyone with settings access. The flag is what makes the platform treat it as a credential.
  • dry_run defaults on. A first sync against somebody else’s source of truth should never be a surprise.
  • The schedule ships disabled. Two deliberate acts stand between installing this and it writing to a production NetBox on a timer. See Scheduling.

Resource Limit
ZIP archive (compressed) 50 MB
ZIP archive (uncompressed) 200 MB
schedules entries 10
schedules[].interval_seconds 60 to 86400
python_dependencies entries 50
Automation triggers 50 per plugin
Automation actions 50 per plugin
AI tools 20 per plugin
Registered devices 1,000 per plugin
HTTP response (via PluginHTTPClient) 10 MB
HTTP timeout 60 seconds
AI tool result payload 256 KB
Settings blob per PUT /settings 32 KiB

Two validators exist for plugin.yaml: the runtime (backend/app/plugins/schema.py) and the dev SDK (freesdn-sdk/sdk/src/freesdn_sdk/manifest.py). They disagree in ways that matter:

Area Runtime (authoritative) Dev SDK (freesdn-sdk validate)
python_dependencies specifiers name==version only; loose specifiers rejected Accepts >=, ~=, and other PEP 440 forms
public_routes field Present and validated Not present; silently ignored
Scaffold template N/A Generates requests>=2.28 (which the runtime rejects)

Always test your manifest against the runtime before shipping to the marketplace or distributing the ZIP.


Use the dev SDK CLI to catch obvious problems early, then confirm against the runtime.

  1. Scaffold a new plugin:

    Terminal window
    freesdn-sdk init my-plugin --author "Your Name" --description "What it does"
  2. Validate the manifest locally:

    Terminal window
    freesdn-sdk validate
  3. Run a static import check:

    Terminal window
    freesdn-sdk check
  4. Build the ZIP:

    Terminal window
    freesdn-sdk package
    # produces my-plugin-1.0.0.zip
  5. Install against a real FreeSDN instance (requires super_admin):

    Terminal window
    curl -X POST https://your-instance/api/v1/plugins/install \
    -H "Authorization: Bearer <token>" \

Method Path Purpose Required role
GET /api/v1/plugins List installed plugins org_admin
GET /api/v1/plugins/{id} Plugin detail including cached manifest org_admin
POST /api/v1/plugins/install Install from uploaded ZIP super_admin
POST /api/v1/plugins/install-url Install by downloading from a URL; requires PLUGIN_ENABLE_DIRECT_URL_INSTALLS=true and host in PLUGIN_ALLOWED_DOMAINS super_admin
DELETE /api/v1/plugins/{id} Uninstall and remove files super_admin
POST /api/v1/plugins/{id}/upgrade Upgrade via new ZIP super_admin
POST /api/v1/plugins/{id}/enable Enable (org-scoped or global) org_admin / super_admin
POST /api/v1/plugins/{id}/disable Disable without removing org_admin / super_admin
GET /api/v1/plugins/{id}/health Runtime health for your org org_admin
GET /api/v1/plugins/{id}/settings Read org-scoped settings org_admin
PUT /api/v1/plugins/{id}/settings Write org-scoped settings org_admin
GET /api/v1/plugins/{id}/public-auth Public-route auth status - whether a secret is set, declared routes, and required header names org_admin
POST /api/v1/plugins/{id}/public-auth/rotate-secret Generate new HMAC secret (returned once) org_admin

  • Plugin System overview - two-tier model, lifecycle hooks, SDK interfaces, and the automation and AI bridges
  • Marketplace - browsing, installing from the marketplace, catalog signing, and the Ed25519 publisher key
  • Fabric catalog - how plugin triggers, actions, and AI tools surface in the universal app-interconnect

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.