Skip to content

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.

{
"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": []
}

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 includes tenant-x and data.foo. Only a vendor system plugin may sit there.
  • An id nested with one already loaded (foo alongside foo-bar) — ownership of a table would otherwise be undecidable.

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.

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:invoke opens the reverse channel, but the capability behind it has its own gate. tag.write is mounted under data:write. Declaring only capability:invoke gets you permission_denied on every write — and the message will not name the permission you left out.

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"]

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 sidecar entry is 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 declare requires”), forbidden for in-process (“requires is 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": { "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.

A list. Each entry:

{
"category": "route",
"id": "acme.tcp",
"order": 10,
"label": "TCP",
"paletteGroup": "pages",
"meta": { "path": "/acme.tcp" }
}
  • category is deliberately free-form. The host renders a few well-known categories: route, nav, slot, command, settings, ai-tool, connector, pageType, app.
  • id must be yours: equal to the plugin id, or a -/.-delimited child of it. foo does not match foobar.
  • order and label drive ordering and labelling; a missing order counts as 0 and ties are broken by id.
  • paletteGroup is the command-palette rubric: quick-actions, pages or settings. Without it a command lands under pages.

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" }
}
}

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.tcp gives the prefix acme_tcp.
  • Column types: text, integer, numeric, timestamptz, boolean. There are no others.
  • id, tenant_id and created_at are created by the platform itself. Declaring them is an error.
  • tables must contain exactly the names tableSchemas describes.
  • An uploaded package with tables but no tableSchemas is 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 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.

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.