Skip to content

Build your first plugin

Plugins run in a V8 isolate and can only access capabilities declared by the package, approved by an administrator and enforced by the CMS gateway.

Install Node.js 22+, pnpm 10+, and the zcms CLI:

Terminal window
npm install -g @zcmsorg/cli@latest
Terminal window
zcms init ./hello --kind plugin --id com.acme.plugin.hello

Use a reverse-DNS id under a domain you control. init asks for anything you leave out, and refuses to write into a directory that already holds something.

You get a project that builds, typechecks, tests, packs and signs with nothing changed:

hello/
├── plugin.json # the manifest
├── package.json
├── build.mjs # esbuild -> ONE CommonJS file
├── tsconfig.json
├── src/index.ts
├── test/plugin.test.ts
└── .gitignore # ignores *.pem — your signing key lives here later
Terminal window
cd hello
pnpm install
pnpm build
pnpm test

The rest of this page explains what the generated files contain, so you can change them with confidence.

Create plugin.json at the package root. The required fields are id, name, version, author and engine. entry defaults to dist/index.js; permissions declares the scopes requested at activation time.

{
"id": "com.example.plugin.hello",
"name": "Hello Plugin",
"version": "0.1.0",
"description": "Adds a read-only content helper.",
"changelog": "- First public release.",
"author": {
"name": "Example Studio",
"url": "https://example.com"
},
"engine": ">=0.1.0",
"entry": "dist/index.js",
"scope": "site",
"permissions": ["content:read"],
"capabilities": ["hello.content-helper"],
"media": {
"screenshots": ["screenshots/admin.png"]
}
}

Optional plugin fields include changelog, scope, capabilities, media, settingsSchema and database.tables. Prefer ctx.storage; declare relational tables only when necessary, and keep every table inside the plugin-specific prefix enforced by the platform.

The optional changelog field holds this version’s release notes — a short list of what changed since the previous version. Administrators see it as a “What’s new” note in the plugin manager and when they review an update, so they understand what a new version does before approving it. Write plain text with one change per line (blank lines and tabs are allowed); the limit is 2000 characters. Update changelog every time you bump version.

scope declares the plugin’s activation reach and is either "site" (the default) or "org":

  • "site" — installed and activated per website. This is the default and fits most plugins (SEO, commerce, an AI assistant, and so on).
  • "org" — installed once for the whole organization (tenant) and runs on every website that organization owns, like a WordPress “network-activated” plugin. Administrators manage it from a separate Organization plugins screen.

scope is reach, not authority: an "org" plugin still has its permissions approved at the tier it installs at, and never becomes a core plugin. The platform reads scope from the signed manifest, so a package cannot widen its own privileges through this field. See Themes and plugins for how administrators enable plugins at each tier.

Implement the generated entrypoint with @zcmsorg/plugin-sdk types and APIs.

  • Use only APIs exposed by the SDK.
  • Obtain content, media and mail access through the provided gateway.
  • Handle the case where an optional permission was not approved.
  • Keep secrets in the platform secret store; never bundle them in the package.
  • Do not import database packages, filesystem APIs or Node.js built-in modules outside the runtime contract.

Build the plugin and start the Z-CMS development environment. Install the local development build on a test site, then activate it for that site only.

Check the following before continuing:

  1. The plugin loads without runtime or schema errors.
  2. Each feature works with the minimum documented permissions.
  3. The plugin fails safely when a permission is denied.
  4. Deactivation stops hooks and background work cleanly.
  5. Reactivation does not duplicate jobs, webhooks or stored configuration.

Add automated tests for the plugin lifecycle, permission checks and invalid input. Include negative tests that try to access another tenant, use an unapproved permission and call APIs outside the SDK contract.

Run the project’s typecheck, lint and test scripts. Fix every failure before packaging.

  1. Leave the version to zcms pack (step 5) — it stamps the current version and advances it, keeping the manifest and package.json in sync; pass --set-version to pin an exact one.

  2. Update the changelog note in the manifest and the permission disclosure.

  3. Build from a clean checkout with the committed lockfile.

  4. Generate the publisher key pair once if you do not already have one:

    Terminal window
    zcms keygen --out ./keys
  5. Copy plugin.json, dist and required runtime assets into a clean release directory, then pack that directory:

    Terminal window
    zcms pack ./build/package --kind plugin \
    --key ./keys/publisher-private.pem \
    --pub ./keys/publisher-public.pem \
    --out ./release/hello-plugin-0.1.0.zcms
  6. Verify the publisher signature:

    Terminal window
    zcms verify ./release/hello-plugin-0.1.0.zcms
  7. Record the checksum printed by zcms pack.

zcms pack excludes src, node_modules, .git, .env, source maps and build-tool configuration. It sorts entries and zeroes archive timestamps so packing the same built directory produces identical bytes — add --no-bump when you re-pack to check this, so the version is not advanced between runs.

The first zcms verify checks the publisher signature only. Marketplace adds its own co-signature during intake; a runtime still refuses the publisher-only package.

Continue with Publish a package for publisher verification, Marketplace submission, review, signing and release.