Plugin Development
VeloCMS plugins are written against a typed API surface (the velocms global: posts, media, settings, network, logger) and a capability manifest that declares what each plugin may touch. Today every plugin on VeloCMS is first-party: the VeloCMS team builds them, and they run in-process on the server under capability checks. The platform also contains a V8-isolate sandbox (isolated-vm) designed for third-party code, but no third-party plugin is enabled, so nothing runs inside it yet. The isolate model described below is that design, kept here as the reference for the contract.
The isolate model (reserved for third-party code)
When third-party plugins are enabled, each plugin execution is designed to create a fresh isolated-vm Isolate, inject the VelocmsPluginAPI bridge as a copy-on-interact host object, run your plugin code inside that isolate, then destroy 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: 500ms 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 isolate: 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 a plugin's manifest.json and reviewed by the VeloCMS team before a plugin becomes available. A tenant sees exactly what the 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 (Admin → 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.
Third-party submissions
There is no submission process today. The /developers pages don't accept plugins, and there's no review queue, listing form or payout flow behind them. When VeloCMS opens plugin development to outside developers, the process will be documented here first. Until then, plugins are built by the VeloCMS team, and the way to influence what gets built is to send an integration request to [email protected].
Reference
Hook catalog
| Hook | Fires when | Can modify payload | Payload type |
|---|---|---|---|
| afterPostCreate | A new post draft is saved | No | { post: Post } |
| afterPostPublish | A post is published | No | { post: Post } |
| beforePostPublish | Just before publish — for validation or enrichment | Yes | { post: Post } |
| afterPostUpdate | A post is edited and saved | No | { post: Post, previousPost: Post } |
| afterPostDelete | A post is permanently deleted | No | { postId: string, slug: string } |
| afterMemberSignup | A new reader signs up (magic link) | No | { member: BlogMember } |
| afterMediaUpload | An image or file is uploaded to R2 | No | { media: Media } |
| onDailyDigest | Daily cron at 07:00 UTC | No | { date: string } |
Capability manifest options
| Capability | What it enables | Risk level |
|---|---|---|
| content:read | posts.get, posts.list, media.get, media.list | Low |
| content:write | posts.create, posts.update | Medium |
| media:read | media.get, media.list | Low |
| settings | settings.get, settings.set (scoped to plugin namespace) | Low |
| network | network.fetch (URL allowlist declared in manifest required) | Medium |
VelocmsPluginAPI surface
The following fields are available on the velocms global inside every plugin sandbox. Fields marked Optional are only present when the corresponding capability is declared in manifest.json.
| Field | Type | Optional | Description |
|---|---|---|---|
| content | ContentAPI | No | Content read/write. Specific methods are gated by capabilities: |
| network | NetworkAPI | Yes | Outbound HTTP. Only available if `capabilities.network` is declared. |
| settings | SettingsAPI | No | Encrypted key-value store scoped to this plugin + tenant. |
| logger | PluginLogger | No | Structured logging. Always available. |
Plugin kill-switch behavior
| State | How it's triggered | Effect | Recovery |
|---|---|---|---|
| enabled=true | Default after install | Hooks fire on every matching event | — |
| enabled=false (manual) | Admin toggles off in Admin → Plugins | Hook loader skips all handlers for this plugin | Admin toggles on |
| enabled=false (auto) | 3 consecutive hook failures in any 24h window | VeloCMS auto-disables + sends email to tenant admin | Admin acknowledges + re-enables |
| deleted | Admin uninstalls the plugin | Plugin entry removed from site_settings.installed_plugins | Re-install from marketplace |
Sandbox limits
| Limit | Default | Configurable | Exceeded behavior |
|---|---|---|---|
| Memory per isolate | 32 MB | No (platform policy) | Isolate terminated; hook chain continues without this plugin |
| CPU time per hook | 500ms | No (platform policy) | Isolate terminated with timeout error in execution log |
| Outbound fetch URLs | Allowlist only (manifest) | Yes (per-plugin manifest) | CapabilityError thrown; fetch returns rejected promise |
| Concurrent hook calls | 1 per plugin per tenant | No | Queue — second call waits until first resolves |
Plugin manifest — minimal required fields
{
"id": "your-org.plugin-name",
"name": "Human Readable Name",
"version": "1.0.0",
"description": "One sentence.",
"author": { "name": "Your Name", "email": "[email protected]" },
"capabilities": [],
"hooks": ["afterPostPublish"],
"builtin": false,
"engines": { "velocms": ">=1.0.0" },
"pricing": { "model": "free" }
}Hook handler TypeScript signature
// Every hook handler follows this shape:
export async function hookName(
payload: HookPayload, // typed per-hook (see Hook catalog above)
velocms: VelocmsPluginAPI // the sandbox API bridge
): Promise<void | Partial<HookPayload>> {
// Return void for observation-only hooks (afterPost*, afterMember*, onDailyDigest)
// Return Partial<HookPayload> to modify the subject (beforePostPublish only)
}Testing with mockVelocms — full API
import { createMockVelocms } from "@velocms/plugin-sdk/testing";
const velocms = createMockVelocms({
// Pre-seed posts for content:read testing
posts: [{ id: "1", title: "Test Post", slug: "test-post", status: "published", ... }],
// Pre-seed settings for settings API testing
settings: { "my-plugin.api-key": "test-key-123" },
});
// The mock exposes vi.fn() spies on all methods:
expect(velocms.logger.info).toHaveBeenCalledWith("...", expect.any(Object));
expect(velocms.content.posts.list).toHaveBeenCalledOnce();
// Reset between tests:
velocms.reset();