Appearance
Manifest reference
manifest.json is the contract between a plugin and the runtime. It is validated at load against packages/shared/src/manifest.ts; an invalid manifest means the plugin is skipped. Unknown top-level fields are rejected (typo protection), so the table below is the complete allowed set.
Minimal example
json
{
"name": "guestbook",
"version": "0.1.0",
"api_version": "^1.0",
"author": "you",
"description": "A simple server guestbook.",
"type": "standalone",
"backend": { "entry": "backend/index.ts" },
"frontend": { "entry": "frontend/index.html" },
"permissions": ["data.sql:self", "broadcast.clients"]
}Top-level fields
| Field | Type | Required | Since | Status | Notes |
|---|---|---|---|---|---|
manifest_version | 2 | no | v2 | stable | Manifest document schema version. Absent means 1. An unknown value fails closed rather than being guessed by shape. |
name | string (slug) | yes | v1 | stable | The plugin's stable identity and directory name. Lowercase slug, matching ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$ — must start with a letter, no leading, trailing, or consecutive hyphens. There is no separate slug field. |
version | string (semver) | yes | v1 | stable | Strict MAJOR.MINOR.PATCH. No pre-release or build metadata. |
api_version | string (caret range) | yes | v1 | stable | The runtime→plugin contract range this plugin targets, e.g. "^1.0". Checked against the server's PLUGIN_API_VERSION before install, so an incompatible plugin is refused before anything is stopped or copied. |
author | string | yes | v1 | stable | Display name of the publisher. Non-empty. |
description | string | yes | v1 | stable | One-line summary shown in the admin Plugins panel and the marketplace. Non-empty. |
license | string | no | v1 | stable | SPDX identifier or free text. |
icon | string (lucide icon name) | no | v1 | stable | Icon shown in the admin Plugins panel, e.g. "Hash". Max 64 characters. An unknown name renders as the default placeholder. |
type | "core" | "standalone" | "extension" | yes | v1 | stable | How this plugin relates to other plugins. This is a RELATIONSHIP axis and is orthogonal to runtime.kind, which describes what the plugin's own code is. See Plugin type & extends. |
extends | string (slug) | iff type: "extension" | v1 | stable | Slug of the base plugin. Required when type is "extension", and rejected otherwise. |
dependencies | Record<pluginSlug, semverRange> | no | v1 | stable | PLUGIN-to-plugin dependencies, resolved at boot. Not npm packages — those live in package.json. |
runtime | { kind: "ui-only" | "sandboxed-backend" } | iff manifest_version: 2 | v2 | stable | What the plugin's own code is. Exactly two kinds: ui-only serves a static frontend to a sandboxed iframe; sandboxed-backend additionally runs bun --smol run under the seccomp+Landlock jail. Inferred when absent (a backend block means sandboxed-backend, frontend-only means ui-only), so no v1 plugin needs an edit. Required to be explicit when manifest_version is 2. See ui-only example. |
backend | { entry: string } | one of the two | v1 | stable | Backend entry point, relative to the plugin root. At least one of backend or frontend is required. |
frontend | { entry: string } | one of the two | v1 | stable | Frontend entry point (an HTML file). Only BUILT assets ship — a frontend-src/ directory is never packaged. |
permissions | string[] | yes | v1 | stable | Host-sandbox capabilities the PLUGIN PROCESS requests, as resource.action[:scope]. May be empty — a frontend-only plugin needs none. Blanket wildcard scopes are rejected. Distinct from capabilities, which grants USERS things. See Permissions. |
capabilities | { key, label?, defaultTier }[] | no | v1 | stable | User-facing capabilities this plugin defines for the Admin/Member tiers. Each is registered as a named permission granted to its default tier and above. |
client_capabilities | string[] | no | v1 | stable | Client features the plugin requires. Currently only "client.browser". |
runtime_capabilities | string[] | no | v1 | stable | Runtime APIs the plugin opts into (voice.media, voice.screen_share, voice.moderation). An unrecognized value is a hard reject, never a silent drop. |
managed_services | string[] | no | v1 | stable | Runtime-supervised sidecar services the plugin requests, by registered slug. Recognized today: "livekit". Presence in the registry is checked by the resolver, which can see it. |
public_schema | Record<table, { columns, description }> | no | v1 | stable | Tables this plugin exposes for cross-plugin reads. Underscore-prefixed names are reserved for runtime internals (_config holds secrets) and cannot be exposed. See public_schema. |
resources | { memory_mb?, cpu_weight?, disk_mb? } | no | v1 | stable | Requested resource envelope, as positive integers. Parsed and persisted today; aggregate accounting and enforcement land with governance. |
data | { dir_env?, migrate?: "app-export" | "volume-copy" | "none" } | no | v2 | stable | The plugin's DATA contract, so the platform can back up and migrate state without knowing the plugin's internals. Code and data are separate: an update swaps code and preserves data. |
sidebar | { contributes, refresh_on?, section?, emits_delta? } | no | v1 | stable | Whether and how the plugin contributes sidebar items. See Sidebar. |
file_search | { contributes: boolean } | no | v1 | stable | Opt in to answering the shell's searchFiles action so bottom-bar media search fans out to this plugin. |
settings | PluginSetting[] | no | v1 | stable | Admin-configurable settings rendered as a form. Values live in the plugin's _config table; type "secret" is redacted from logs and masked in the UI. See Settings. |
event_subscriptions | string[] (dotted topics) | no | v1 | stable | Topics outside the plugin's own namespace that its FRONTEND may receive. Undeclared topics are default-deny. Max 32. |
serve_ready_handshake | boolean | no | v1 | stable | Defaults to false. Opt into the two-stage handshake: the plugin is not serveable until it calls plugin.serveReady(). Use it when post-spawn hydration must finish before user requests arrive. See Lifecycle. |
proxy_mounts | ProxyMount[] | no | v1 | stable | Reverse-proxy mounts, each disabled until an owner approves it. Requires proxy.http:self or proxy.websocket:self in permissions. See proxy_mounts, Reverse-proxy guide. |
trust | { posture: "source-available" | "adapter" | "closed" } | no | v2 | reserved (Phase 3) | DISCLOSURE posture only. Publisher identity and the signature live in the detached package envelope, never here — a signature cannot live inside the bytes it signs. |
companions | CompanionDeclaration[] | no | v2 | experimental | Preview workload declarations provisioned by the native supervisor. The complete launch, isolation, health, dependency and resource contract is enforced; availability remains operator-gated while the companion lane is exercised. |
Status — stable is implemented and enforced. reserved fields are validated and stored but NOT implemented: declaring one is not silently ignored, it fails the install with a typed error naming the phase, so a plugin can never look installed-and-working while a capability it declared does nothing. Unknown top-level fields are rejected outright, so a typo fails loudly at install time instead of leaving you convinced a setting is wired up.
Plugin type & extends
type | Meaning | extends |
|---|---|---|
core | Shipped by UnCorded (text-channels, voice-channels, members, moderation). | forbidden |
standalone | Third-party plugin with its own functionality and data. | forbidden |
extension | Third-party plugin that extends a base plugin. | required — the base plugin slug |
Most third-party plugins are standalone.
Settings
Each entry in settings[] is rendered as a form field in Server settings and is readable via plugin.settings.
json
{
"key": "max_message_length",
"label": "Max message length",
"description": "Maximum characters allowed per message.",
"type": "number",
"default": 5000,
"stops": [
{ "value": 2000, "label": "2k" },
{ "value": 5000, "label": "5k" },
{ "value": 0, "label": "Unlimited" }
]
}| Field | Type | Applies to | Notes |
|---|---|---|---|
key | string | all | Unique within the plugin. Max 256 chars. |
label | string | all | Shown in the admin panel. |
description | string | all | Optional help text. |
type | "string" | "secret" | "number" | "boolean" | — | secret values are redacted from logs and masked in the UI. |
required | boolean | all | Surfaced as a warning if unset. |
default | string | number | boolean | all | Must match type. Used when unset. |
min / max / step | number | number | Bounds and slider step (step > 0). |
stops | { value, label }[] | number | Stepped slider with labelled positions. Stored value is the underlying number (e.g. 0 = "unlimited"). |
max_length | number | string/secret | Server-enforced length cap (positive). |
enum | string[] | string | Renders a select; default must be a member. |
Cross-field validation enforces min ≤ default ≤ max, default length ≤ max_length, default ∈ enum, and default matching a stops value.
Sidebar
json
{ "sidebar": { "contributes": true, "section": "Chat", "refresh_on": ["text-channels.channel.created"] } }| Field | Type | Notes |
|---|---|---|
contributes | boolean | Required. true if the plugin returns sidebar items. |
section | string | Optional default group name for this plugin's items, used when an item doesn't set its own section. |
refresh_on | string[] | Event topics that trigger a re-fetch of the plugin's sidebar items. |
emits_delta | boolean | Declare true when the backend calls plugin.sidebar.emitDelta; the full sidebar.items response remains authoritative. |
The items themselves come from the backend's sidebar.items handler, not the manifest. See Plugin anatomy → reserved actions.
public_schema
Declares which of your tables and columns are readable by other plugins (via their data.read capability):
json
{
"public_schema": {
"messages": {
"columns": ["id", "channel_id", "author_id", "content", "created_at"],
"description": "All messages across all channels."
}
}
}Only listed columns are readable; everything else stays private.
proxy_mounts
json
{
"proxy_mounts": [
{ "name": "demo", "upstream_setting": "demo_upstream_url", "access": "members" }
]
}| Field | Type | Notes |
|---|---|---|
name | string | Slug-safe, unique within the plugin. Appears in the URL /proxy/\<slug\>/\<name\>/*. |
upstream_setting | string | Key of a string/secret setting in this same manifest holding the upstream URL. The manifest never carries the URL directly. |
access | "members" | "owner" | Optional, default "members". |
max_frame_bytes | integer | Optional. Max WebSocket frame (message) size relayed in either direction, in bytes. A larger frame closes the socket with 1009. Default 65536 (64 KiB); raise it for apps that bulk-sync over a socket (e.g. game state). Must be between 1024 (1 KiB) and 16777216 (16 MiB). |
Declaring proxy_mounts requires at least one of proxy.http:self / proxy.websocket:self in permissions. Mounts are disabled until an owner approves them. Full guide: Reverse-proxy plugins.
Validation rules (summary)
- At least one of
backend/frontend. type: "extension"⇒extendspresent and a valid slug;core/standalone⇒ noextends.- Every
permissionsentry matches the capability grammar. proxy_mounts[].upstream_settingreferences a declaredstring/secretsetting; mount names unique; proxy permission present.proxy_mounts[].max_frame_bytes, when present, is an integer in[1024, 16777216].- Settings
defaultconsistent withtype/min/max/max_length/enum/stops. resources.*positive integers;icon≤ 64 chars; unknown top-level or per-setting fields rejected.sidebar.contributes: truerequires abackend— sidebar items are served by the backend'ssidebar.itemshandler, so a frontend-only plugin cannot contribute them (SIDEBAR_REQUIRES_BACKEND).
Manifest v2
manifest_versionis optional; absent means 1, so every v1 manifest stays valid with no edit. An unrecognized value is rejected rather than guessed by shape.runtime.kindis inferred when absent: abackendblock ⇒sandboxed-backend, frontend-only ⇒ui-only. Declaring a kind that contradicts the tree —ui-onlywith a backend, orsandboxed-backendwithout one — isRUNTIME_KIND_MISMATCH, not a silent override.manifest_version: 2requiresruntimeto be explicit.- Using any v2 field raises the
api_versionfloor to^1.1(V2_REQUIRES_API_FLOOR). Older runtimes reject fields they do not know, and the floor makes them refuse the install up front instead of failing after the server has been stopped and the files copied. - Reserved fields fail closed.
trustis validated and stored, but a plugin declaring it does not load — the runtime refuses it withCAPABILITY_UNSUPPORTEDnaming the field and the phase. A capability you declared never silently does nothing. datais stable. It declares the plugin-specific data-directory environment variable and migration strategy so code can be replaced while state remains separate.companions[]is experimental. It is accepted by the runtime and validated against the complete native-supervisor launch contract, including entry point, runtime, health, ports, environment, dependencies and resources. Provisioning remains operator-gated during the preview; an unavailable lane reports a warning instead of silently pretending that the workload started.- A provisioned companion receives
COMPANION_DATA_DIR, the absolute path of its private durable writable directory. The supervisor owns this value; a manifest cannot redirect it. Everything outside that directory remains read-only under the companion sandbox. - Reinstalling an enabled preview plugin refreshes the companion's recorded package/source metadata while retaining its UID, network allocation, private data directory, and current desired lifecycle state.
The tests in packages/shared/src/manifest.test.ts and manifest-schema.test.ts are the exhaustive, executable spec.