Plugin Development
VeloCMS plugins run inside a V8 isolate — a hard sandbox boundary that gives your code access to a typed API surface and absolutely nothing else. No require(), no fs, no process.env. You get the velocms global (posts, media, settings, network, logger) and whatever the tenant's capability manifest allows. This architecture exists because third-party code running on someone's blog is a serious trust problem, and isolated-vm solves it without the overhead of a separate subprocess.
The sandbox model
Every plugin execution creates a fresh isolated-vm Isolate, injects the VelocmsPluginAPI bridge as a copy-on-interact host object, runs your plugin code inside that isolate, then destroys the isolate when the hook returns. Memory is bounded by the isolate memory limit (default: 32MB). CPU time is bounded by a hard timeout (default: 5s per hook execution). If your plugin exceeds either limit, it's killed and the hook chain moves on without it.
What you can't do inside the sandbox: import Node.js modules, call require(), access global process or __dirname, use setTimeout or setInterval (async operations go through velocms.network.fetch), or access the filesystem. What you can do: call velocms.content.posts.list(), read and write settings via velocms.settings, make HTTP requests to allowlisted URLs, and log structured output via velocms.logger.
The capability manifest
Capabilities are declared in your plugin's manifest.json and reviewed by the VeloCMS marketplace team before your plugin goes live. A tenant sees exactly what your plugin needs — and can deny installation if they're not comfortable with the permissions. Here's the full capability list:
| Capability | What it enables | Risk level |
|---|---|---|
| content:read | velocms.content.posts.list, posts.get, media.list, media.get | Low |
| content:write | velocms.content.posts.create, posts.update | Medium |
| media:read | velocms.content.media.get, media.list | Low |
| settings | velocms.settings.get, settings.set (scoped to plugin) | Low |
| network | velocms.network.fetch (URL allowlist required) | Medium |
The hook API
Hooks fire at specific points in the VeloCMS content lifecycle. Your plugin registers handler functions against hook names in its plugin definition. All hooks are asynchronous and receive a typed payload; some hooks (beforePostPublish, beforePostCreate) can return modified payload data that VeloCMS will use instead of the original.
| Hook | Fires when | Can modify payload |
|---|---|---|
| afterPostCreate | A new post draft is saved | No |
| afterPostPublish | A post is published | No |
| beforePostPublish | Just before publish — useful for validation or enrichment | Yes |
| afterPostUpdate | A post is edited | No |
| afterPostDelete | A post is permanently deleted | No |
| afterMemberSignup | A new reader signs up | No |
| afterMediaUpload | An image or file is uploaded | No |
| onDailyDigest | Daily cron (07:00 UTC) — useful for newsletter plugins | No |
Hello World — a complete plugin
Let's build the simplest useful plugin: one that logs a message to the execution log every time a post is published. Three files: manifest.json, index.ts, and a test.
{
"id": "my-org.publish-logger",
"name": "Publish Logger",
"version": "1.0.0",
"description": "Logs a structured message whenever a post is published.",
"author": {
"name": "My Org",
"email": "[email protected]"
},
"capabilities": ["content:read"],
"hooks": ["afterPostPublish"],
"builtin": false,
"engines": {
"velocms": ">=1.0.0"
},
"pricing": {
"model": "free"
}
}import type { VelocmsPluginAPI } from "@velocms/plugin-sdk";
export async function afterPostPublish(
payload: { post: { id: string; title: string; slug: string } },
velocms: VelocmsPluginAPI
): Promise<void> {
velocms.logger.info("Post published", {
postId: payload.post.id,
title: payload.post.title,
url: `/blog/${payload.post.slug}`,
});
}Testing with mockVelocms
The SDK ships a mockVelocms test helper that creates a fully in-memory VelocmsPluginAPI — no real PocketBase connection needed. You can test hook handlers in Vitest without any infrastructure:
import { describe, it, expect } from "vitest";
import { createMockVelocms } from "@velocms/plugin-sdk/testing";
import { afterPostPublish } from "./index";
describe("publish-logger", () => {
it("logs a message with post metadata when afterPostPublish fires", async () => {
const velocms = createMockVelocms();
await afterPostPublish(
{ post: { id: "post123", title: "Hello World", slug: "hello-world" } },
velocms
);
expect(velocms.logger.info).toHaveBeenCalledWith(
"Post published",
expect.objectContaining({ postId: "post123" })
);
});
});The kill-switch mechanism
Every installed plugin can be killed by the tenant from their admin panel (Settings → Plugins) without uninstalling it. Kill-switching a plugin sets its enabled flag to false in site_settings.installed_plugins — the hook loader skips it on every subsequent invocation. No restart required, no redeploy. If a plugin causes errors, VeloCMS auto-disables it after 3 consecutive hook failures and notifies the tenant by email.
Submitting to the marketplace
When your plugin is ready, go to /developers and follow the submission form. You'll upload a ZIP containing manifest.json, your compiled index.js (or TypeScript source), and optionally a README.md. We run automated security scanning (capability declarations vs actual API surface accessed) before human review. Expect 3-7 days for approval on first submission.