Skip to content

Packaging & Publishing

This page covers the full journey from a working plugin directory to an installed, running plugin: building and validating the ZIP, understanding the size and path guards the loader applies, declaring hash-pinned Python dependencies correctly, and choosing between the three install paths - local ZIP upload, direct URL, and the marketplace.

Before you reach this page you should already have a plugin.yaml, a working FreeSDNPlugin subclass, and passing local tests. If not, start with Getting Started and the Manifest Reference.


The freesdn-sdk CLI assembles the archive for you.

Terminal window
pip install freesdn-sdk

From your plugin directory:

Terminal window
freesdn-sdk validate # parse manifest, check entry_point + class exist
freesdn-sdk check # static AST scan for blocked imports and dangerous builtins
freesdn-sdk package # write {id}-{version}.zip next to the plugin dir
freesdn-sdk package -o /path/to/out.zip # explicit output path

Run validate and check before package. Both commands exit 1 on failure. package skips junk directories, symlinks, and .env files automatically.

freesdn-sdk check scans your source for imports from the loader’s blocked-module list (os, subprocess, socket, importlib, ctypes, pickle, and many others) and calls to dangerous builtins (exec, eval, compile, __import__, open, breakpoint, globals, locals, vars). It is a static scan only - it cannot catch obfuscated patterns. Fix every error before submitting.


The loader enforces two hard limits on every ZIP regardless of install path:

LimitValue
Compressed archive size50 MB
Uncompressed total size200 MB

These are enforced before any file is extracted. An archive that exceeds either limit is rejected with a PluginLoadError. The SDK CLI enforces the same numbers during package so you get the error locally rather than at install time.


The loader inspects every member of the archive before extraction. Any member whose resolved path escapes the target extraction directory is rejected. This protects against zip-slip attacks where a crafted archive entry (e.g. ../../etc/cron.d/evil) writes outside the plugin directory.

Plugin authors do not need to do anything to benefit from this - the guard runs unconditionally on every install. If your archive is built with freesdn-sdk package you will not trip it; symlinks are excluded by the CLI for the same reason.


The loader tracks the total uncompressed size as it extracts each member. If the running total crosses 200 MB it stops and raises PluginLoadError. Streaming extraction means the check does not require loading the full decompressed content into memory first.


After a successful install the loader writes the plugin to:

/data/plugins/<plugin_id>/
plugin.yaml
plugin.py
requirements.txt # required only when python_dependencies is non-empty
.venv/ # per-plugin isolated venv (created only when deps are installed)

PLUGIN_DIR defaults to /data/plugins and is read from the environment variable of the same name. You can override it in your deployment.


Most plugins should have no Python dependencies beyond freesdn-sdk (which is pre-aliased at runtime - do not list it). If your plugin wraps a vendor SDK or needs a library the core does not provide, you must declare it.

Every entry in python_dependencies in plugin.yaml must be an exact version pin:

python_dependencies:
- "httpx==0.27.0"
- "pydantic==2.13.0"

Loose specifiers (httpx>=0.25, httpx~=0.27) are rejected by the runtime manifest validator with a PluginLoadError. Extras syntax (httpx[http2]) and environment markers (;) are also rejected.

Declaring dependencies in plugin.yaml is not enough. You must also ship a requirements.txt with --hash=sha256: annotations. The loader refuses to install dependencies without it.

Generate the lockfile with pip-compile:

Terminal window
pip install pip-tools
pip-compile --generate-hashes --output-file requirements.txt requirements.in

The loader cross-checks the manifest: every bare package name in python_dependencies must appear in the lockfile. A mismatch is a PluginLoadError.

The loader runs pip in a subprocess (thread-offloaded, 180 s timeout) with these flags:

pip install \
--require-hashes \
--no-deps \
--no-cache-dir \
--only-binary :all: \
--index-url <PLUGIN_PYPI_INDEX_URL> \
--no-input \
-r requirements.txt

--no-deps means you must pin every transitive dependency yourself. --only-binary :all: blocks sdist builds that execute arbitrary code. --no-cache-dir prevents cache poisoning. The index URL defaults to https://pypi.org/simple/ and can be overridden with PLUGIN_PYPI_INDEX_URL for air-gapped environments.

The venv is installed into .venv/ inside the plugin directory and appended to sys.path at load time - never inserted at position 0, so it cannot shadow core packages.

Python dependency installation requires PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=true in the backend environment. It defaults to false.

Terminal window
# docker-compose override or .env
PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=true

If the flag is not set and python_dependencies is non-empty, install is refused before pip is ever called. This is intentional: production deployments should evaluate dependency installation explicitly rather than having it happen silently.

Env varDefaultPurpose
PLUGIN_ALLOW_RUNTIME_PYTHON_DEPSfalseAllow pip to install plugin deps at install time
PLUGIN_PYPI_INDEX_URLhttps://pypi.org/simple/PyPI index for hash-pinned installs

Upload a built ZIP directly from your workstation via the management API. This is the standard path for private or internally-developed plugins.

Required role: super_admin.

POST /api/v1/plugins/install
Content-Type: multipart/form-data
file: <your-plugin.zip>

The loader runs the full pipeline: size and path guards, manifest parse, lock acquire, plugin directory write, optional dep install, class load and import hygiene, on_install() call, database row creation. On success it returns 201 with the plugin detail.

If the plugin is already in installed state the loader returns an error - use the upgrade path instead.

Install a plugin by having the backend download it from a URL. This path is disabled by default and requires two configuration changes.

Required role: super_admin.

Required configuration:

Terminal window
PLUGIN_ENABLE_DIRECT_URL_INSTALLS=true
PLUGIN_ALLOWED_DOMAINS=plugins.example.com,cdn.vendor.io

PLUGIN_ALLOWED_DOMAINS is a comma-separated list. If it is empty the install is blocked even when PLUGIN_ENABLE_DIRECT_URL_INSTALLS=true. There is no wildcard support.

POST /api/v1/plugins/install-url
Content-Type: application/json
{"url": "https://plugins.example.com/my-plugin-1.0.0.zip"}

The backend download enforces the same SSRF guards as PluginHTTPClient: HTTPS/HTTP only, DNS-resolves and validates the host against the allowlist, pins to the resolved IP, disables redirects, streams with the 50 MB cap. Private and loopback IPs are rejected even if they appear in PLUGIN_ALLOWED_DOMAINS.

The downloaded bytes are then handed to the same install pipeline as a local ZIP upload.

Env varDefaultPurpose
PLUGIN_ENABLE_DIRECT_URL_INSTALLSfalseEnable the install-url endpoint
PLUGIN_ALLOWED_DOMAINS"" (block all)Comma-separated allowlist of permitted download hosts

Install a plugin that appears in the FreeSDN marketplace catalog.

Required role: super_admin.

POST /api/v1/marketplace/plugins/{slug}/install

The backend fetches the archive from download_url in the catalog using the same DNS-safe, redirect-disabled streamed download as the URL path, then verifies the archive SHA-256 against checksum_sha256 from the catalog before passing it to the install pipeline. A checksum mismatch aborts the install.

Browse the catalog (no authentication required at the read layer):

METHOD pathPurpose
GET /api/v1/marketplace/pluginsPaginated list; filter by q, category, sort
GET /api/v1/marketplace/plugins/featuredUp to 6 featured plugins
GET /api/v1/marketplace/plugins/categoriesCategory list with plugin counts
GET /api/v1/marketplace/plugins/{slug}Plugin detail
GET /api/v1/marketplace/plugins/{slug}/versionsVersion history
POST /api/v1/marketplace/plugins/syncSync catalog from remote registry (super_admin)

The catalog is fetched from MARKETPLACE_REGISTRY_URL (default https://registry.freesdn.org/plugins.json). The registry response is capped at 5 MB. The sync endpoint verifies an Ed25519 signature against MARKETPLACE_PUBLISHER_PUBLIC_KEY (pinned hex key). With no key configured and MARKETPLACE_ALLOW_UNSIGNED not set, sync returns 403 - it does not silently trust unsigned catalogs.


All plugin management endpoints require an authenticated session. Install, uninstall, and upgrade require super_admin. Enable, disable, and settings management require org_admin (or the plugins.admin permission).

METHOD pathPurposeRequired role
POST /api/v1/plugins/installInstall from uploaded ZIPsuper_admin
POST /api/v1/plugins/install-urlInstall by downloading a URLsuper_admin + PLUGIN_ENABLE_DIRECT_URL_INSTALLS
POST /api/v1/plugins/{id}/upgradeUpgrade via new ZIP (stops everywhere, reinstalls, restarts)super_admin
DELETE /api/v1/plugins/{id}Uninstall (runs on_uninstall, removes files)super_admin
GET /api/v1/pluginsList installed plugins with org-effective statusorg_admin
GET /api/v1/plugins/{id}Plugin detail including cached manifestorg_admin
POST /api/v1/plugins/{id}/enableEnable (globally if super_admin with no org, else org-scoped)org_admin
POST /api/v1/plugins/{id}/disableDisable without removingorg_admin
GET /api/v1/plugins/{id}/settingsOrg-scoped settingsorg_admin
PUT /api/v1/plugins/{id}/settingsUpsert org-scoped settings (max 32 KiB)org_admin
GET /api/v1/plugins/{id}/healthRuntime health for caller’s orgorg_admin
GET /api/v1/plugins/{id}/public-authPublic-route HMAC auth status (has_secret, public routes, required header names)org_admin (org required)
POST /api/v1/plugins/{id}/public-auth/rotate-secretGenerate new org-scoped HMAC secret for public/webhook routes; returns plaintext secret onceorg_admin (org required)

Every install, uninstall, upgrade, enable, disable, and secret-rotation action is written to the audit log with the plugin and supply-chain tags.

Enabling and disabling behaves differently depending on the caller’s scope:

  • Global scope (super_admin with no org context): toggles InstalledPlugin.is_active and starts or stops the plugin for all organisations.
  • Org scope (org_admin): writes or removes a PluginOrganizationState row for that organisation. Absence of a row means the plugin inherits the global state. A globally-disabled plugin returns 409 if an org tries to enable it.

Disabling a plugin does not unregister its routes - those remain until the backend restarts. Requests to the plugin’s routes return 410 Gone while it is disabled.


  1. Build and validate the new ZIP with freesdn-sdk validate, freesdn-sdk check, and freesdn-sdk package.
  2. Upload to POST /api/v1/plugins/{id}/upgrade with the new ZIP as multipart file.
  3. The loader acquires the per-plugin lifecycle lock, stops the plugin for all organisations, reinstalls the plugin files and optional dependencies (via the same pipeline as a fresh install), loads the new class, calls on_upgrade(from_version, db) on the newly-loaded instance, and then starts the plugin for every active org that has not explicitly disabled it.
  4. Check GET /api/v1/plugins/{id}/health for each org to confirm the runtime is healthy after upgrade.

Work through this list before distributing a plugin.

  1. freesdn-sdk validate exits 0 with no warnings.
  2. freesdn-sdk check exits 0 - no blocked imports, no dangerous builtins.
  3. All python_dependencies entries are name==version exact pins.
  4. requirements.txt is present and generated with --generate-hashes if there are any deps.
  5. Every permission the plugin exercises is declared in plugin.yaml permissions[].
  6. AI tools each declare a real permission string - an undeclared permission coerces to a super_admin-only sentinel.
  7. plugin_id does not clash with a reserved ID (see Manifest Reference).
  8. version is strict semver MAJOR.MINOR.PATCH (all digits, exactly three parts).
  9. homepage starts http:// or https:// if provided.
  10. The ZIP is under 50 MB compressed and would be under 200 MB uncompressed.

ResourceLimit
ZIP compressed size50 MB
ZIP uncompressed size200 MB
Python dependencies (manifest entries)50
Automation triggers per plugin50
Automation actions per plugin50
AI tools per plugin20
Devices registerable per plugin1,000
AI tool result payload256 KB
Plugin settings blob32 KiB
Outbound HTTP timeout60 s
Outbound HTTP response10 MB
HMAC timestamp skew300 s

  • Manifest Reference - every plugin.yaml field with validation rules and the SDK-vs-runtime divergences.
  • Getting Started - scaffold, implement, and test your first plugin end-to-end.
  • Plugins Overview - the two-tier trust model and what SDK plugins can and cannot do.