45 KiB
Extension Author Guide
ServerKit is a small core of primitives plus optional extensions installed from the Marketplace. This document is the honest, single reference for building one: the manifest schema, the contribution envelope, lifecycle hooks, the backend SDK, install sources, and — importantly — the constraints you will hit in production.
If you only remember one thing: an extension's frontend must be compiled into the panel's Vite bundle to render. For builtin (in-repo) extensions we handle this by pre-bundling (see The production frontend constraint). A third-party extension that ships only a backend works everywhere today; one that ships a frontend needs the delivery mechanism tracked in Phase 3 of the platform plan.
Anatomy of an extension
An extension is a folder (zipped for distribution) with this layout:
my-extension/
plugin.json # manifest — the only required file
backend/ # optional: Flask blueprint + services + lifecycle hooks
blueprint.py
lifecycle.py
frontend/ # optional: React components exposed to the host UI
index.jsx # entry module — named exports referenced by contributions
styles/
my-extension.scss
backend/is extracted tobackend/app/plugins/<slug>/on the panel host and its blueprint is hot-loaded (no restart needed for install).frontend/is extracted tofrontend/src/plugins/<slug>/. For builtin extensions this directory is checked in (a build artifact — see below).- Either half is optional. A pure-frontend extension (like
serverkit-git, whose backend API stays in core) declares onlycontributions; a pure-backend extension declares anentry_pointand no frontend.
plugin.json manifest
{
"name": "serverkit-git", // required — slug: ^[a-zA-Z0-9_-]+$
"display_name": "Git Server", // required
"version": "1.0.0", // required — semver recommended
"description": "…",
"author": "ServerKit",
"homepage": "https://…",
"repository": "https://…",
"license": "MIT",
"category": "deployment", // ai|monitoring|security|deployment|integration|ui|utility
"icon": "<svg…>", // optional — rendered on the marketplace detail view
"screenshots": ["https://…/1.png"], // optional — rendered on the detail view
"permissions": ["docker", "filesystem"], // docker|filesystem|shell|network|db
"min_panel_version": "1.7.0", // optional compat gate (enforced at install)
"max_panel_version": "2.0.0", // optional
"entry_point": "blueprint:git_bp", // backend: module:bp_var under app.plugins.<slug>
"url_prefix": "/api/v1/git", // defaults to /api/v1/<slug>
"templates": ["gitea"], // app-template ids to install alongside
"lifecycle": { // module:func hooks under app.plugins.<slug>
"install": "lifecycle:on_install",
"uninstall": "lifecycle:on_uninstall",
"upgrade": "lifecycle:on_upgrade"
},
"models": ["models:register"], // optional — plugin-owned tables (see Data models)
"config_schema": { … }, // optional — rendered as a settings form
"contributions": { … } // UI contributions — see below
}
The authoritative, machine-readable contract is served at
GET /api/v1/plugins/manifest-spec and mirrors what plugin_service.py actually
consumes — keep them in sync when extending the schema.
The contributions envelope
Everything an extension adds to the host UI is declared here. Each entry is
tagged at runtime with its source plugin slug so the frontend can resolve
component strings against the right module.
"contributions": {
"nav": [
{ "id": "git", "label": "Git", "route": "/git",
"category": "infrastructure", "icon": "<circle …/>",
"requiresCondition": "gpuAvailable" } // optional runtime gate
],
"routes": [
{ "path": "git", "component": "GitExtensionPage" },
{ "path": "git/:tab", "component": "GitExtensionPage", "layout": "padded" }
],
"page_titles": { "/git": "Git Repositories" },
"command_palette": [
{ "label": "Git", "path": "/git", "category": "Pages", "keywords": "repos deploy" }
],
"widgets": [ { "slot": "dashboard.top", "component": "GitStatusWidget" } ],
"dashboard_widgets": [
{ "id": "git-activity", "name": "Git activity", "component": "GitActivityWidget",
"category": "Operations", "description": "Recent pushes across repositories",
"w": 4, "h": 3, "min": [3, 2], "default_cfg": { "limit": 6 } }
],
"layouts": [ { "id": "my-fullscreen", "component": "MyLayout" } ],
"tabs": [
{ "group": "files", "to": "/ftp", "label": "FTP Server", "icon": "<rect …/>" }
],
"ai": {
"suggested_prompts": [ { "route": "/git", "label": "…", "prompt": "…" } ],
"tool_renderers": [ { "tool": "git__list_branches", "component": "BranchList" } ]
}
}
| Kind | Shape | Notes |
|---|---|---|
nav |
{id,label,route,category,icon,requiresCondition?} |
icon is raw inner-SVG markup. category: overview/infrastructure/operations/system. Merged into the sidebar; deduped by id. |
routes |
{path,component,layout?,group?} |
component = a named export of the plugin's index.{js,jsx}. layout: padded (default) / full / bare / a custom layout id. group nests the route inside a core tab group instead — see Tab-group contributions. |
page_titles |
{ "/path": "Title" } |
Sets document.title. |
command_palette |
{label,path,category,keywords} |
category defaults to Extensions. |
widgets |
{slot,component} |
Slots: global (renders inside DashboardLayout), plus the enrich-core slots (dashboard.top, service.detail.tab, domain.drawer.panel). Fixed position — the host decides where it renders. |
dashboard_widgets |
{id,name,component,icon?,category?,description?,w?,h?,min?,default_cfg?} |
Registers a placeable widget type in the dashboard widget library. The user adds, moves, resizes and configures instances on their own boards. category: Metrics/Inventory/Operations/Utility (defaults to Extensions). w/h = default span in a 12-column grid; min = [minW,minH]. The component receives { widget, ctx } — see Dashboard widgets. |
layouts |
{id,component} |
Custom wrappers; the component must render <Outlet/>. Built-in ids padded/full/bare are reserved. |
tabs |
{group,to,label,icon?,end?,order?} |
Adds a tab to a core-owned tab group. group = the group id (files / servers / monitoring). See Tab-group contributions. |
ai |
{suggested_prompts,tool_renderers} |
See AI. |
Route layouts
padded(default) — insideDashboardLayout, normal padding.full— insideDashboardLayout, no padding (like/files,/docker).bare— outsideDashboardLayout(no sidebar), under the auth guard — fullscreen authenticated pages.<custom-id>— wrapped in alayouts-contributed component.
Dashboard widgets
The dashboard is a 12-column grid of user-placed widgets. dashboard_widgets
registers a widget type so it appears in the widget library and users can
drop instances onto their own boards.
Don't confuse the two widget surfaces:
widgets |
dashboard_widgets |
|
|---|---|---|
| Where it renders | a fixed host slot you name | anywhere the user drags it |
| Who decides position | the host | the user |
| Instances | one per slot | many, each independently configured |
| Use it to | enrich an existing page | offer a new building block |
"dashboard_widgets": [
{ "id": "git-activity",
"name": "Git activity",
"component": "GitActivityWidget", // named export of your index module
"category": "Operations", // Metrics|Inventory|Operations|Utility
"description": "Recent pushes across repositories",
"w": 4, "h": 3, "min": [3, 2], // default span / minimum span
"default_cfg": { "limit": 6 } }
]
Ids are namespaced by the panel as <plugin-slug>:<id>, so two extensions can
both ship a activity widget without colliding.
Your component receives two props:
export function GitActivityWidget({ widget, ctx }) {
// widget.cfg — this instance's config, seeded from default_cfg
// widget.w / widget.h — its current span, if you want to adapt density
// ctx.range — '1h' | '6h' | '24h' | '7d' | '30d' (the board's time range)
// ctx.tick — increments on each refresh; put it in your effect deps
// ctx.serverVar — the board's selected server ('local' or a server id)
// ctx.isAdmin, ctx.navigate
return <div className="skw-list">…</div>;
}
Render into the space you're given: the frame's header, menu and resize handle are the host's. A widget that throws is caught and replaced with an error tile rather than taking the board down, but handle your own loading and empty states — every core widget does.
Tab-group contributions
Some core surfaces are tab groups (one shared PageTopbar + routed tabs,
rendered by TabGroupLayout). An extension can add a tab to one of these
groups instead of contributing a standalone page, so its feature sits where
users expect it (e.g. FTP as a tab of Files) and the group's chrome stays.
Two halves, both required:
"tabs": [ { "group": "files", "to": "/ftp", "label": "FTP Server",
"icon": "<rect …/>", "order": 1 } ],
"routes": [ { "path": "ftp", "component": "FtpServerPage", "group": "files" },
{ "path": "ftp/:tab", "component": "FtpServerPage", "group": "files" } ]
- The
tabsentry puts the tab in the strip; thegroup-tagged routes render the page inside that group'sTabGroupLayout(agrouproute ignoreslayout). groupids match the group's sidebar item id:files,servers,monitoring. The host also extends that sidebar item's highlight to the contributed tab's path, so the group stays lit. (Other groups can accept contributions later by passinggroupIdto theirTabGroupLayoutinApp.jsx.)iconis raw inner-SVG markup (24×24 viewBox, stroked), like nav icons.orderis an optional insertion index; default appends after the core tabs. Core tabs always win atocollision.- Pages rendered in a tab group must not render their own top bar; publish
actions via the
useTopbarActions()outlet context like core tab pages do.
The production frontend constraint
frontend/src/plugins/contributions.js discovers plugin UI modules at build
time via import.meta.glob('../plugins/*/index.{js,jsx}'). Two consequences you
must design around:
- Builtin (in-repo) extensions work in production because their frontend
halves are checked into
frontend/src/plugins/<slug>/and compiled into every shipped bundle. "Install" just flips the runtime contribution envelope on. - A third-party extension can now render on a production panel without a
rebuild — if it ships a prebuilt ESM bundle (see below). The panel serves
a pre-built bundle and nothing rebuilds Vite, so a checked-in
.jsxfrontend still won't appear; but adist/index.mjsbundle is fetched, integrity-checked, and blob-imported at runtime by the client loader. Its backend half loads fine either way.
Runtime frontend delivery (prebuilt ESM bundle)
An installed extension whose manifest declares a .mjs frontend_entry is
delivered to the running panel without a rebuild (core-slim #39):
// plugin.json
{
"frontend_entry": "dist/index.mjs", // an ESM bundle (NOT a .jsx source file)
"sdk_version": "^1.0.0" // semver range the panel SDK must satisfy
}
How it works: at install time plugin_service records the bundle's sha256 (stored
under the panel-managed _frontend_hashes config key). The
/api/v1/plugins/contributions envelope then advertises a frontends descriptor
map — { slug: { entry, hashes, sdk_version } } — plus the panel's sdk_version.
The client loader (frontend/src/plugins/runtime/loader.js) fetches the bundle
through the JWT-authed assets route, verifies the sha256, refuses it if its
sdk_version range doesn't cover the panel, then blob-imports it — resolving the
externalized bare specifiers through the host import map. Failures render a fail-soft
card on the extension's routes, never a white screen.
Build convention — externalize exactly the specifiers the host import map
resolves (react, react-dom, react/jsx-runtime, react-router-dom,
serverkit-sdk) so the extension shares the panel's singleton instances:
// vite.config.js (extension repo)
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: { entry: 'src/index.jsx', formats: ['es'], fileName: () => 'index.mjs' },
outDir: 'dist',
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime',
'react-router-dom', 'serverkit-sdk'],
},
},
});
Operators can disable runtime delivery panel-wide with the
extensions.runtime_frontend system setting (kill switch, default on); baked
builtins keep rendering via the build-time glob regardless.
D5 — builtin frontends are pre-bundled (single source of truth)
For an in-repo extension, builtin-extensions/<slug>/frontend/ is the source of
truth; frontend/src/plugins/<slug>/ is a generated artifact. Never edit the
artifact by hand. Regenerate it with:
node scripts/sync-builtin-frontends.mjs # source → artifact
node scripts/sync-builtin-frontends.mjs --check # CI drift gate (fails on drift)
The Extensions CI workflow runs --check on every change to
builtin-extensions/** or frontend/src/plugins/**, so the two can never
silently diverge.
Backend SDK (app.plugins_sdk)
Depend on the SDK, not on host internals — as long as this surface is stable, core refactors won't break you.
from flask import Blueprint, request, jsonify
from app.plugins_sdk import (
db, jwt_required, current_user, audit, logger,
ai, queue, notify, jobs,
)
my_bp = Blueprint('my_ext', __name__)
log = logger(__name__)
@my_bp.route('/things', methods=['GET'])
@jwt_required()
def list_things():
user = current_user()
return jsonify({'ok': True})
| Name | What it is |
|---|---|
db |
SQLAlchemy handle (db.session, db.Model). |
jwt_required, get_jwt, get_jwt_identity |
Flask-JWT-Extended re-exports. |
current_user() |
Resolves the JWT identity to a User row (or None). |
audit(action, target_type, …) |
Write an audit-log entry. |
logger(name) |
Module-scoped logger. |
ai |
Extend the core assistant (tools, context, prompts). |
queue |
Queue Bus SDK (publish/consume). |
notify |
Notifications SDK (notify.send(event, to, data)). |
jobs |
Jobs SDK (schedule/enqueue background work). |
sockets |
Register a status-guarded Socket.IO namespace (/ext/<slug>). |
store |
Per-plugin key/value storage — state without a model and a migration. |
deploys |
Register a deployment kind; get the Deploy Console, live logs and retry. |
backups |
Register a backup kind; get policies, retention, restore and the Protection panel. |
doctor |
Register a health check; it appears on the doctor page, repair button and all. |
search |
Register searchable entities; they appear in the command palette. |
agents |
Run a command on a managed server (gated by agent.command:<action>). |
require_permission(slug, cap) |
Capability gate — raises PermissionDenied if cap isn't declared in permissions, and records the call (allowed or refused). Only mediates what routes through it; see Permissions & compatibility. |
panel_version() |
The panel's version string (for in-plugin compat checks). |
Errors follow the core convention: return jsonify({'error': 'message'}), status.
Contributing to panel surfaces
Six of those surfaces work the same way: you register behaviour, the panel owns the UI, the persistence and the error handling. Nothing on the frontend needs to change for your contribution to appear.
from app.plugins_sdk import store, deploys, backups, doctor, search, agents
# State, without adding a model + migration to core.
mine = store.for_plugin('serverkit-minecraft')
mine.set('world:1:seed', 8675309)
# Long-running work that someone will want to watch → the Deploy Console.
deploys.register('minecraft.restart', restart_handler)
deploys.start('minecraft.restart', steps=['Warn players', 'Save', 'Restart'],
plan={'title': 'Restarting Overworld'})
# Something worth backing up → policies, schedules, retention, restore.
backups.register('minecraft.world', resolve=resolve, execute=execute, restore=restore)
# "Is my thing healthy?" → the doctor page, with a working Repair button.
doctor.register('minecraft', check_worlds, repair=back_up_stale)
# Your objects → the command palette. Scope rows to query.user yourself.
search.register('minecraft.world', find_worlds)
# The fleet. Declare agent.command:<action> in your manifest first.
agents.for_plugin('serverkit-minecraft').run(server_id, 'systemd:restart',
{'unit': 'minecraft'})
Namespace whatever you register after your plugin (minecraft.world) — bare
names belong to core and are refused. Each registry's module docstring is the
detailed contract; the shapes they hand back are normalised and capped by the
panel, so a malformed contribution degrades rather than breaking the surface it
appears on.
Blueprint registration & the disable guard
entry_point (module:bp_var) is imported from app.plugins.<slug>.<module> and
registered at url_prefix. A before_request guard is attached automatically:
when the plugin's DB status isn't active, its routes return 503 — so
disabling an extension actually stops serving it, even though Flask can't
unregister a blueprint from a running app.
Keep the same /api/v1/<feature> prefix when a feature moves from core into an
extension (decision D9) so existing agents/scripts/UI keep working.
Lifecycle hooks
Declared under lifecycle; resolved as module:func under app.plugins.<slug>.
Each hook receives the InstalledPlugin row as its single positional arg.
install— runs after files are extracted (e.g. seed default rows).upgrade— runs when installing a version different from the installed one.uninstall— runs before files are removed. Receives whether the caller requested a data purge (see Data policy).
Hook failures are logged and swallowed — hooks are convenience, not correctness.
Data models & policy
Raw db access works, but for tables you own, declare a models entry point so
the platform can create/upgrade/clean them up:
# app/plugins/<slug>/models.py
def register(ctx):
"""Return SQLAlchemy model classes owned by this extension.
Table names are prefixed ext_<slug>_ automatically."""
...
- Install creates the tables; the
upgradehook runs on version change. - Uninstall offers keep-data vs purge (mirrors the installer's
--purgesemantics); the choice is passed to theuninstallhook (func(plugin, purge=...)) and, on purge, drops theext_<slug>_*tables.
The core data seam (extension uses CORE tables)
The ext_<slug>_* mechanism is for extension-owned schema. The deliberate
exception — core data seam (plan 52 D1) — is an extension whose tables are
core Alembic-managed because they carry live data that predates the extraction
and are entangled with core features. Canonical example: serverkit-wordpress
— WordPressSite, WordPressVulnerability, WordPressUpdateRun,
WordPressReport live in app/models/wordpress_site.py (core migrations own
their lifecycle) and the standalone extension imports them:
from app.models.wordpress_site import WordPressSite
Rules of the seam: the extension never creates/migrates those tables (no
models entry for them, no ext_ rename); core never imports the extension —
it reaches it through a lazy bridge (app.services.wordpress_bridge) or
get_installed_extension_attr, and through registration seams the extension
fills (core_hooks). Absent extension = feature absent, gracefully. The same
seam covers StatusPage, Tunnel/ExposedService, and CloudServer (owned
by their respective extensions' features but schema-core).
Background jobs & schedules
Declare handlers and recurring jobs in the manifest; they wire into the Jobs SDK on install and pause automatically when the plugin is disabled:
"jobs": [ { "kind": "myext.reindex", "handler": "jobs:reindex" } ],
"schedules": [ { "name": "myext-nightly", "kind": "myext.reindex", "cron": "0 3 * * *" } ]
Core hooks (core_hooks)
Some core features are engines with a registration seam — backup target types
(app.services.backup_kind_registry), the event-type catalog
(app.services.event_service.register_event_types), provider-owned app
templates (TemplateService.register_template_provider). Declare a zero-arg
function that fills them and the platform calls it at install and on every
boot while your extension is active (so it must be idempotent):
"core_hooks": "core_hooks:register"
Core keeps the engine; your extension supplies the entry. An extension that is
absent/disabled at boot never registers, so its target types, event types, and
template cards simply don't exist — that is the intended graceful degradation
(plan 52 D4), not an error. Reference implementation: the serverkit-wordpress
repo's backend/core_hooks.py (the wordpress_site backup kind, the
wordpress.* event types, and the wordpress/wordpress-external-db template
provider).
Config (config_schema)
Declare a config_schema in the manifest and the Marketplace renders a
Configure form on the installed plugin (Installed tab). Values persist on
the panel and your backend reads them with plugins_sdk.config(slug):
"config_schema": {
"api_key": { "type": "string", "secret": true, "description": "…" },
"refresh_seconds": { "type": "integer", "default": 60 },
"mode": { "type": "string", "enum": ["fast", "thorough"] },
"enabled": { "type": "boolean", "default": true }
}
- Top-level keys are the field names (a JSON-schema-style
propertieswrapper also works). Supported:string/number/integer/boolean,enum(renders a select),default,title,description, andsecret: true(renders a password input). - Values may hold secrets, so they are not part of the plugin's public
dict — only the admin-gated
GET/PUT /api/v1/plugins/<id>/configserves them, andplugins_sdk.config()is read-only.
from app.plugins_sdk import config
key = config('my-extension').get('api_key')
Real-time (Socket.IO)
Declare "socket_entry": "sockets:register"; the function returns
{event: handler} and the panel registers them on /ext/<slug>, status-guarded
(a disabled plugin's namespace refuses new connections):
# app/plugins/<slug>/sockets.py
def register():
def on_connect(): ...
def on_subscribe(data): ...
return {"connect": on_connect, "subscribe": on_subscribe}
Localization
Extensions use the panel's own i18n — not a parallel one. i18next and
react-i18next are shared singletons (see vendorManifest.js), so an
extension's copy follows the user's language, plural rules and live locale
switch with no wiring of its own.
import { useTranslation, useFormat } from 'serverkit-sdk';
export function MyPage() {
const { t } = useTranslation();
const { formatDate } = useFormat();
return (
<>
<h2>{t('myext.title', 'My Extension')}</h2>
<p>{t('myext.lastRun', 'Last run {{when}}', { when: formatDate(run.at) })}</p>
</>
);
}
Three rules, the same ones core follows:
- Always pass the English default —
t('key', 'English'). It is what renders when the active locale has no entry for your key, so a partially translated extension degrades to English instead of showing a raw key path. - Namespace your keys under your slug (
myext.*). The panel merges extension bundles into one resource tree; an unprefixedtitlewould collide with core's. - Never call
toLocaleDateString()/new Intl.*yourself. With no argument those follow the browser's locale, not the panel's, so your dates would disagree with every date around them. UseuseFormat()(or theformatDate/formatNumber/formatRelativeexports) — they read the panel locale and cache their formatters.
For a sentence containing a link or an element, use <Trans> rather than
splitting it into prefix/suffix keys — word order around the element differs by
language and split keys cannot be reordered by a translator.
Declarative labels in the manifest
Manifest contributions are data, so they cannot call t() — a label resolved
at module load would translate once, at import, and never follow a switch.
Declare the key beside the English instead, and the panel resolves it at render:
"contributions": {
"nav": [
{ "id": "myext", "route": "/myext", "labelKey": "myext.nav", "label": "My Extension" }
],
"tabs": [
{ "group": "servers", "to": "/myext/tab", "labelKey": "myext.tab", "label": "My Tab" }
],
"command_palette": [
{ "path": "/myext", "labelKey": "myext.palette", "label": "Open My Extension" }
]
}
labelKey is optional everywhere. Omit it and the panel renders label
verbatim, which is exactly what every pre-existing extension does today.
Shipping translations
Register your bundles against the shared instance at module load:
import i18next from 'i18next';
import es from './locales/es.json';
i18next.addResourceBundle('es', 'translation', { myext: es }, true, true);
Because the instance is shared, this must be additive and namespaced — do not
call i18next.init() or changeLanguage() from an extension: the panel owns
both, and calling them would reconfigure or switch the language for the whole
panel.
Guided walkthrough contributions
Extensions can add safe, declarative guides to the panel's Walkthroughs dock. The panel namespaces every contributed id with the extension slug, filters the guide through the declared RBAC requirements, and removes it immediately when the extension is disabled. No extension JavaScript is needed for route, manual, or target-visible steps:
"contributions": {
"walkthroughs": [{
"id": "first-site",
"title": "Launch your first site",
"description": "Create the site and verify that it is online.",
"duration": "About 5 minutes",
"icon": "globe",
"tone": "green",
"secondary": true,
"permissions": [{ "feature": "applications", "level": "write" }],
"steps": [{
"id": "open-create",
"title": "Open the create flow",
"description": "Start from the extension's site list.",
"action": "Create site",
"path": "/my-extension/new",
"target": "myext-create-site",
"completion": { "type": "route", "path": "/my-extension/new" }
}, {
"id": "launch-site",
"title": "Launch the site",
"description": "Submit the reviewed configuration.",
"target": "myext-submit-site",
"completion": { "type": "signal", "signal": "myext.site-created" }
}]
}]
}
target is the value of a stable data-walkthrough attribute, never a CSS
selector. For the example above, the extension renders
data-walkthrough="myext-submit-site". This restriction prevents a guide from
reaching into password fields or depending on fragile generated class names.
Five completion modes are supported:
| Type | Completes when |
|---|---|
manual |
The operator chooses Done · next step. |
route |
The current path matches completion.path (or the step's path). |
signal |
The UI emits the named signal after a successful operation. |
check |
A host-owned, allowlisted status check returns success. |
target |
The stable data-walkthrough target becomes visible. |
Emit success signals through the SDK only after the operation succeeds:
import { emitWalkthroughSignal } from 'serverkit-sdk';
await createSite(values);
emitWalkthroughSignal('myext.site-created', { siteId });
Definitions cannot contain JavaScript, commands, arbitrary API calls, or raw
selectors. The same validator runs during extension install and when an admin
publishes a custom guide from Walkthrough Studio. The editor schema lives at
docs/schemas/walkthrough.schema.json; TypeScript definitions live at
frontend/src/plugins/sdk/walkthroughs.d.ts.
Checking your own extension
The panel's censuses take a --root, so run them against your repo instead of
copying them (copies drift — that is how the vendor shims broke):
SK=/path/to/ServerKit/frontend/scripts
node $SK/check-i18n-literals.mjs --root frontend --report # untranslated copy
node $SK/check-intl-door.mjs --root frontend --report # browser-locale dates
The first lists user-visible strings not addressed by a key; the second lists
toLocaleDateString() / new Intl.* calls, each of which follows the
browser's locale rather than the panel's. --update is refused with
--root: those ceilings belong to the panel, not to your extension.
frontend/src/plugins/serverkit-gui/ in the panel repo is a worked example —
a builtin extension converted to the SDK surface, keys namespaced under gui.*.
Permissions & compatibility
permissions is a consent signal, and for most capabilities that is all it is.
Be precise about which half applies, because the difference matters to anyone
deciding whether to trust an extension.
What is actually mediated. require_permission(slug, cap) raises
PermissionDenied unless cap is declared. That gate is real, but it only fires
where something calls it. Today exactly one capability is routed through it
unavoidably:
| Capability | Mediated? | Why |
|---|---|---|
agent.command:<action> |
Yes | The SDK is the only way to reach an agent, so every use passes the gate. Uses and refusals are recorded and shown on the extension's detail page. |
docker, shell, filesystem, network, db |
No | The SDK exposes no helper for these (and db is raw SQLAlchemy), so an extension imports the host module directly and no in-process check is involved. |
So declaring docker does not stop an extension from using Docker, and not
declaring it does not stop it either. What declaring does is tell the operator, at
install time, what the extension says it needs — and let them refuse.
Why the panel says "cannot be observed". The extension detail page compares
declared permissions against observed use. For agent.command:* an unused
declaration is real evidence of over-asking. For the five host capabilities the
panel reports that use cannot be observed rather than showing a zero: absence of
evidence is not evidence of absence, and a "never used" badge there would imply an
enforcement boundary that does not exist.
Declare honestly regardless. The consent dialog is what the operator agrees to, and under-declaring to look harmless is the behaviour the curated registry exists to catch. True out-of-process sandboxing is deliberately out of scope — see ADR 0001 / plan #42 for the posture.
Python dependencies. A requirements.txt in your zip is not installed by
default: pip would run with the backend's privileges and a setup.py hook is
arbitrary code. The file is saved next to the installed extension and surfaced on
its detail page so an operator can review and install it deliberately. Operators
opt in with SERVERKIT_ALLOW_PLUGIN_PIP=1. Design your extension to degrade
clearly when an optional dependency is absent rather than crashing on import.
Version compatibility
Three independent bounds, all optional, all fail-open when omitted:
| Field | Bounds | Checked |
|---|---|---|
min_panel_version / max_panel_version |
the panel release | install and update, every source (URL/upload/local/builtin/registry) |
sdk_version |
the frontend SDK surface (a semver range) | install, and again at load for runtime ESM bundles — before any bytes are fetched |
| (none) | — | an extension that pins nothing installs and loads anywhere |
Pin sdk_version when you import something the SDK only started exporting in a
given release; that is what turns "white screen on an older panel" into a
readable "needs panel SDK ^1.3.0 (this panel offers 1.2.0)" at install time.
| SDK | Added |
|---|---|
^1.5.0 |
Declarative walkthrough authoring: manifest contributions, validation/normalization helpers, success-signal emission, and the walkthrough runtime hook. |
^1.3.0 |
Localization: useTranslation, Trans, t, useLocale, useLabel, useFormat and the formatDate/formatNumber/formatRelative door. i18next and react-i18next became shared singletons, so an extension that externalizes them needs this SDK or newer. |
^1.2.0 |
Layout/feedback primitives, the ui/* form kit, common hooks, format utils, and the cross-feature embeds (git repo-connect, backups ProtectionPanel). |
Both bumps were additive: an extension pinned to ^1.2.0 keeps installing and
loading on a 1.3.0 panel. Pin the range you actually use — over-pinning locks
your users out of upgrades for no reason.
Extending the AI assistant
The assistant is core (decision D7) — you never rebuild it, you extend it. Declare
an ai block and ship app/plugins/<slug>/ai.py:
from app.plugins_sdk import ai
def register(reg): # reg is a PluginToolBinder
@reg.tool(rbac_feature="git", rbac_level="read")
def list_branches(repo: str) -> list:
"""List branches in a repo. Args: repo: repository slug."""
from app.services.git_service import GitService
return GitService.list_branches(repo)
Tools are namespaced <slug>__<name>, RBAC-gated per tool, and write tools
(is_write=True) always route through confirmation.
Built-in deployment workflow
Administrators can use the Assistant tab in the bottom console to ask, for example, "Deploy nginx as demo-web on the panel host." The assistant discovers templates, inspects the selected server, and prepares a deployment preview. Approval displays server-derived application, target, and public input details. The proposal expires after 15 minutes and is bound to the approving user and the template contents; changed templates require a new preview.
Execution creates a persistent Recipe operation using the existing template installer. Open in Operations follows its logs and verification after chat closes. Reusing the same proposal reconnects to the same operation. Retrying a failed verification skips completed installation steps; an interrupted or failed child installation requires inspection in Operations before proceeding.
The built-in Recipe capabilities are apply:template.install and
verify:application.health. Verification checks running containers and any
configured Docker health checks; it does not test HTTP endpoints or logins.
Catalog recipes with missing capability handlers are reported as unsupported.
Repository templates still use the repository deployment form. Credentials
must be supplied through deployment forms or Recipe handoffs, never chat.
The tool implementations live in backend/app/services/ai_operations.py and
the durable handlers in backend/app/services/recipe_builtin_steps.py.
Prompture continues to supply the assistant's model/provider layer; these
tools reuse ServerKit's execution and authorization services.
Install sources
| Source | How | Endpoint |
|---|---|---|
| Builtin (in-repo) | One-click from the Marketplace | POST /api/v1/plugins/builtin/<slug>/install |
| GitHub / release / zip URL | Paste a URL | POST /api/v1/plugins/install |
| Uploaded zip | Upload (≤ 50 MB) | POST /api/v1/plugins/install-upload |
| Local path (dev) | Panel-host path | POST /api/v1/plugins/install-local |
| Registry | Curated index (checksum-verified) — see EXTENSIONS_REGISTRY.md | via Marketplace Browse |
All sources funnel through one install pipeline (_install_from_buffer) so
behavior is identical. Zip-slip is rejected (absolute paths, .., escaping
entries). Python requirements.txt is not installed unless the operator sets
SERVERKIT_ALLOW_PLUGIN_PIP=1 (installing runs pip with the backend's
privileges).
Installable straight from GitHub
Your extension is installable from a raw GitHub URL with no registry entry required — that's the "paste a repo, get a safe install" path. Make it work by honoring this contract:
plugin.jsonat the archive root. The installer reads the manifest from the top level (GitHub zipball nesting is handled). Noplugin.jsonat the root is the single most common failure.- Prefer a release with a
.zipasset. Tag a release and attach your packaged.zip; the installer picks the.zipasset over the source zipball, giving a reproducible, checksum-pinnable download. With no release, it falls back to the default-branch source archive and the preview warns. - Accepted forms: a repo URL, a release URL, a direct
.zip, or the shorthandowner/repoandowner/repo@tag.
Before anything lands, the panel previews the install: it resolves and
downloads the source, reads your manifest, and shows the user a consent card
with the version, the declared permissions (the same "This extension
requests:" block registry entries show), panel-version compatibility, and any
warnings (no release found, slug already installed, version-gate mismatch). The
subsequent install is pinned to the exact previewed bytes via sha256, so what
is installed is byte-identical to what was previewed. Declaring your
permissions honestly is therefore what the user sees and agrees to — under- or
over-declaring both read badly at the consent step.
Private repos and GitHub's anonymous rate limit are handled by the optional
SERVERKIT_GITHUB_TOKEN env var (Bearer auth, attached only to GitHub hosts,
never logged). See POST /api/v1/plugins/preview and
the site's Installing guide.
Release signing (ed25519)
Release zips can carry a detached ed25519 signature so the panel proves origin, not just integrity (a sha256 in the index only says the bytes match the index — whoever can edit the index can edit the hash beside it). The scheme is deliberately minimal (plan 55, D3): no PKI, no key servers.
Signature format — a minisign-style envelope, base64 of 74 bytes:
"ED" (2 bytes) || key_num (8 bytes) || ed25519 signature (64 bytes)
EDmarks a pure ed25519 signature over the raw zip bytes.key_numissha256(public_key)[:8], binding the envelope to the exact key that made it — apublisher_key_idcannot be pointed at a different pinned key than the one that signed.
Sign a release with scripts/sign-extension.mjs:
# once per publisher — keep the key file PRIVATE and out of git
node scripts/sign-extension.mjs keygen --key-id my-publisher --out <safe dir>
# per release — writes <bundle>.zip.minisig and prints the index fields
node scripts/sign-extension.mjs sign my-ext-1.0.0.zip --key <safe dir>/my-publisher.signing-key.json
Ship the .minisig beside the zip as a release asset (the GitHub preview flow
looks for <zip URL>.minisig automatically). For registry-listed extensions,
paste the printed signature + publisher_key_id into the index entry
(schema v3; v1/v2 indexes without them stay valid — those entries are simply
treated as unsigned).
Key model. The panel pins publisher public keys in
backend/app/data/extension_signing_keys.json (the serverkit-official
first-party key ships there). Operators can trust additional publisher keys via
SERVERKIT_TRUSTED_EXTENSION_KEYS (path to a JSON file with the same shape) —
that file is also the rotation path: pin the new key, sign new releases with
it, remove the old entry once nothing references it.
What the panel does with a signature (verify logic:
app/services/signing_service.py):
| Verdict | Meaning | Install behavior |
|---|---|---|
verified |
Signature valid under a pinned key | Installs; no extra friction |
unsigned |
No signature present | Installs behind the existing consent surfaces (preview card badge, registry risk dialog) — never a hard block. For first-party registry entries it is a 409 consent gate (possible downgrade); community reviewed entries stay exempt because the review stamp already binds a maintainer verdict to the artifact's exact sha256 |
untrusted_key |
Signed, but the publisher key isn't pinned | Registry: 409 consent ("install anyway"); manual: consent card warns |
invalid |
Malformed envelope, key-id mismatch, or verify failed | Hard failure, always. Acknowledgment can never override a bad signature |
The verdict is stamped on the installed extension (panel-managed _signature
config key, surfaced as signature in the plugin API dict) so origin
verification stays visible after install. The sha256 runtime-frontend hash
check is unaffected — signatures cover the zip, _frontend_hashes still pins
the served .mjs bundle bytes.
All current downloadable first-party releases publish a detached .minisig
asset and carry the same signature in the registry index. Registry CI downloads
the exact release bytes and verifies both SHA-256 and Ed25519 before accepting
the catalog. The unsigned 409 path remains only for older cached indexes and
third-party compatibility; it is not the normal first-party delivery path.
Docker note
A dockerized backend only sees /app, not the host's frontend/ tree. To install
an extension that ships a frontend on such a panel, bind-mount the host's
frontend/src/plugins into the container and set
SERVERKIT_FRONTEND_PLUGINS_DIR, or run the backend natively for development.
Converting a core page into a builtin extension (recipe)
Proven with serverkit-git; automated by the one-shot upgrade auto-install
(so existing users never lose a page). Steps:
- Create
builtin-extensions/<slug>/plugin.jsonwith thecontributions(nav / routes / page_titles / command_palette) that reproduce the page. - Create
builtin-extensions/<slug>/frontend/index.jsx— usually a thin re-export of the existing host page while the backend API stays in core:import GpuMonitor from '../../pages/GpuMonitor'; export function GpuMonitorPage() { return <GpuMonitor />; } - Remove the hardcoded entries from
App.jsx(import +<Route>+PAGE_TITLES),sidebarItems.js(or the group*Tabs.jsx), andCommandPalette.jsx. - Pre-bundle:
node scripts/sync-builtin-frontends.mjs. - Lint the manifest:
node scripts/new-extension.mjs --validate builtin-extensions/<slug>— the same shape rules are enforced at install time, so catching a malformed contribution here saves a failed install. - The backend API stays core for now (two-speed extraction, decision D2). Full backend extraction happens only after the Phase 3 primitives exist.
Existing panels auto-install converted builtins once on upgrade (a marker in settings) so nothing disappears; fresh installs see them in the Marketplace.
Second speed — moving the backend out too (plan 47)
Once a converted page has settled as a frontend-only extension, its backend blueprint + service can leave core:
git mv backend/app/api/<feature>.py builtin-extensions/<slug>/backend/<module>.pyand the same for its service(s). Rewrite imports: core imports stay absolute (from app.middleware.rbac import …), sibling extension modules become relative (from .<sibling> import …).- Add
backend/__init__.pyexposing the blueprint, and set the manifest'sentry_point("<module>:<bp>") +url_prefix(unchanged so frontend API clients don't move). - Deregister it from
backend/app/__init__.py(drop the import +register_blueprint). - Keep any model used by the extension in
backend/app/models/(extensions can't run migrations — G2). The extension imports it. - If a core code path calls into the now-extracted service (a job handler,
the agent gateway), reach it through
plugin_service.get_installed_extension_attr(slug, module, attr)so it no-ops cleanly when the extension is absent. - Ensure the slug is in
extension_migration.CONVERTED_BUILTIN_SLUGS. On an upgraded panel that installed the extension frontend-only (its API used to come from core),run_backend_acquisition()force-reinstalls once to re-acquire the now-extracted backend — otherwise the API would vanish.
Landed this way: serverkit-ftp, serverkit-cloud-provision,
serverkit-remote-access, serverkit-status.