The plugin manifest
manifest.json is the whole contract with the host. It sits as exactly one file at the
root of the archive. The host validates it before anything is loaded; a mistake in it is a
refusal, not a crash.
Check it with your own compiler instead of finding out at install time:
import { readFileSync } from 'node:fs';import { parseManifest } from '@prodlog/sdk';
const manifest = parseManifest(JSON.parse(readFileSync('manifest.json', 'utf8')));parseManifest throws a ZodError carrying the precise path to the problem. There is also
parseUploadedManifest — that is the upload path’s pre-filter, not a second validation
path; you do not need it while building.
The smallest useful shape
Section titled “The smallest useful shape”{ "id": "acme.tcp", // lowercase, dot- or dash-separated "version": "0.1.0", // semver "sdk": "^0.9.0", // the contract range you built against "system": false, // always false; true belongs to the platform and is refused "permissions": ["data:write"], // what your CODE may reach "rbacPermissions": [], // what a PERSON may do, in your namespace "requiresLicense": false, "backend": { "entry": "backend.js" }, "provides": [], "tables": []}The fields, one by one
Section titled “The fields, one by one”Lowercase, segments separated by . or -: ^[a-z0-9]+(?:[-.][a-z0-9]+)*$. This id is
your namespace, and everything else hangs off it.
What the host refuses:
shell— that belongs to the shell itself.- An id whose first segment is a reserved namespace:
plugin,audit,user,data,tenant,system,capability. That includestenant-xanddata.foo. Only a vendor system plugin may sit there. - An id nested with one already loaded (
fooalongsidefoo-bar) — ownership of a table would otherwise be undecidable.
version and sdk
Section titled “version and sdk”version is strict semver (major.minor.patch, pre-release allowed). An update must be
strictly higher; a downgrade is refused with version_not_higher.
sdk is a range. See “What a plugin is” for the table of which contract versions a host
provides.
permissions — what your code may do
Section titled “permissions — what your code may do”A closed enumeration:
| Permission | What for |
|---|---|
data:read |
Reading queries through the mediated surface |
data:write |
Writing queries — and the capabilities mounted under it |
plugin:manage |
Managing plugins (in practice, system plugins only) |
capability:invoke |
The reverse channel: calling a host capability |
audit:read |
Reading the audit trail |
net:request |
One outbound HTTP(S) request, mediated by the host |
net:socket |
A held, bidirectional connection — what a connector needs |
net:request and net:socket are separate because they are separate risks: a REST call
leaves a trail of discrete, bounded requests, while a held socket is an open channel for as
long as the plugin lives.
The one that gets missed.
capability:invokeopens the reverse channel, but the capability behind it has its own gate.tag.writeis mounted underdata:write. Declaring onlycapability:invokegets youpermission_deniedon every write — and the message will not name the permission you left out.
rbacPermissions — what a person may do
Section titled “rbacPermissions — what a person may do”An open list in the form namespace:verb, that is
^[a-z0-9]+(?:[-.][a-z0-9]+)*:[a-z0-9]+(?:-[a-z0-9]+)*$. The namespace must be your
plugin id. A reserved namespace is accepted only on a system plugin, and that check takes
precedence over the ownership check — even if your id happens to be a reserved word.
"rbacPermissions": ["acme.tcp:configure", "acme.tcp:reset-counter"]backend
Section titled “backend”Two kinds, one field:
// in-process: one TypeScript/JavaScript module in an isolated runtime"backend": { "entry": "backend.js" }
// sidecar: your own process, your own language, your own libraries"backend": { "entry": "runtime/main.py", "runtime": "sidecar", "requires": { "language": "python", "minVersion": "3.11" }}The two differ in exactly two points, and each is checked with its own message:
entry. In-process it is a file name at the archive root — no/, no leading dot. A sidecarentryis a relative path into the package tree; every segment must start with a letter or digit, which makes.and..impossible as segments by construction.requires. Mandatory for a sidecar (“a sidecar backend must declarerequires”), forbidden for in-process (“requiresis only meaningful for a sidecar backend”). The host checks the interpreter before it spawns a process — one clear refusal instead of a traceback deep inside somebody else’s code.
A bare "backend": true from before SDK 0.6.0 is still accepted and means “no executable
part”. Do not use it for anything new.
frontend
Section titled “frontend”"frontend": { "entry": "remoteEntry.js" }A file name, not a path. The file must live under dist/ in the archive. A backend-only
package leaves the field out and ships no dist/ — and that is correct.
provides — the contributions
Section titled “provides — the contributions”A list. Each entry:
{ "category": "route", "id": "acme.tcp", "order": 10, "label": "TCP", "paletteGroup": "pages", "meta": { "path": "/acme.tcp" }}categoryis deliberately free-form. The host renders a few well-known categories:route,nav,slot,command,settings,ai-tool,connector,pageType,app.idmust be yours: equal to the plugin id, or a-/.-delimited child of it.foodoes not matchfoobar.orderandlabeldrive ordering and labelling; a missingordercounts as 0 and ties are broken byid.paletteGroupis the command-palette rubric:quick-actions,pagesorsettings. Without it a command lands underpages.
Two categories carry hard rules of their own:
route — meta.path must be /<id> or /<id>/<slug>[/<slug>…], each segment a plain
lowercase slug. Traversal, dotted segments and encoded tricks are impossible here by
construction.
pageType — the contribution id becomes a route segment, so on top of the
ownership rule it must also be a clean segment. meta is validated against the page-type
contract; a malformed one makes the whole manifest invalid, so no page type ever sits in
the add dialog that nothing can be created from.
{ "category": "pageType", "id": "acme.report", "label": "Report", "meta": { "label": "Report", "defaultIcon": "clipboard-list", "create": { "path": "/reports", "nameField": "name", "refField": "id" } }}tables and tableSchemas
Section titled “tables and tableSchemas”You describe your tables; the host builds them. No text from an uploaded package is ever executed as SQL — identifiers are checked against a strict pattern, types resolved against a closed catalogue, and the DDL is assembled by the system alone.
"tables": ["acme_tcp_log"],"tableSchemas": [ { "table": "acme_tcp_log", "columns": [ { "name": "telegram", "type": "text", "required": true }, { "name": "received_at", "type": "timestamptz" } ] }]The rules:
- The table name must start with your normalised id (
.and-become_):acme.tcpgives the prefixacme_tcp. - Column types:
text,integer,numeric,timestamptz,boolean. There are no others. id,tenant_idandcreated_atare created by the platform itself. Declaring them is an error.tablesmust contain exactly the namestableSchemasdescribes.- An uploaded package with
tablesbut notableSchemasis refused — it could never build those tables, and an uninstall would try to purge tables that never existed.
Row level security and the tenant column are enforced at creation, whatever you declare. A plugin cannot opt out.
system and requiresLicense
Section titled “system and requiresLicense”system is always false for your package. An uploaded manifest declaring system: true
is refused outright, not silently downgraded.
requiresLicense: true puts your plugin behind the operator’s entitlement check. Without
one it does not load (plugin ... is not entitled). Free plugins set false.
The connector type as data
Section titled “The connector type as data”A connector declares its own form, in both languages, and the host renders it without
knowing what your protocol is. The id is not in meta — it is the contribution id, and
that is already yours by construction.
import { connectorContribution, type ConnectorType } from '@prodlog/sdk';
const tcp: ConnectorType = { id: 'acme.tcp', label: { de: 'TCP', en: 'TCP' }, connectionFields: [ { name: 'host', label: { de: 'Host', en: 'Host' }, type: 'text', required: true }, { name: 'port', label: { de: 'Port', en: 'Port' }, type: 'number', required: true, default: 5000 } ], tagFields: [ { name: 'channel', label: { de: 'Kanal', en: 'Channel' }, type: 'select', required: true, options: [{ value: 'data', label: { de: 'Data', en: 'Data' } }] } ], canWrite: true};
// drop this straight into `provides`const contribution = connectorContribution(tcp);Field types: text, password, number, boolean, select. A field can be tied to
another field’s value with showWhen — a hidden field is not required and is not stored,
because a control nobody can see must not be able to block saving.
The price of this design, honestly: the type cannot be computed. There is no field list a connector fetches from a device. In exchange the form can be drawn while the process is broken — which is exactly the screen an operator repairs it with.