Plugin Development

14 min readUpdated 27 Apr 2026

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:

CapabilityWhat it enablesRisk level
content:readvelocms.content.posts.list, posts.get, media.list, media.getLow
content:writevelocms.content.posts.create, posts.updateMedium
media:readvelocms.content.media.get, media.listLow
settingsvelocms.settings.get, settings.set (scoped to plugin)Low
networkvelocms.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.

HookFires whenCan modify payload
afterPostCreateA new post draft is savedNo
afterPostPublishA post is publishedNo
beforePostPublishJust before publish — useful for validation or enrichmentYes
afterPostUpdateA post is editedNo
afterPostDeleteA post is permanently deletedNo
afterMemberSignupA new reader signs upNo
afterMediaUploadAn image or file is uploadedNo
onDailyDigestDaily cron (07:00 UTC) — useful for newsletter pluginsNo

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.

manifest.json
{
  "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": "plugins@myorg.io"
  },
  "capabilities": ["content:read"],
  "hooks": ["afterPostPublish"],
  "builtin": false,
  "engines": {
    "velocms": ">=1.0.0"
  },
  "pricing": {
    "model": "free"
  }
}
json
index.ts
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}`,
  });
}
typescript

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:

index.test.ts
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" })
    );
  });
});
typescript

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.

Reference

Hook catalog

HookFires whenCan modify payloadPayload type
afterPostCreateA new post draft is savedNo{ post: Post }
afterPostPublishA post is publishedNo{ post: Post }
beforePostPublishJust before publish — for validation or enrichmentYes{ post: Post }
afterPostUpdateA post is edited and savedNo{ post: Post, previousPost: Post }
afterPostDeleteA post is permanently deletedNo{ postId: string, slug: string }
afterMemberSignupA new reader signs up (magic link)No{ member: BlogMember }
afterMediaUploadAn image or file is uploaded to R2No{ media: Media }
onDailyDigestDaily cron at 07:00 UTCNo{ date: string }

Capability manifest options

CapabilityWhat it enablesRisk level
content:readposts.get, posts.list, media.get, media.listLow
content:writeposts.create, posts.updateMedium
media:readmedia.get, media.listLow
settingssettings.get, settings.set (scoped to plugin namespace)Low
networknetwork.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.

FieldTypeOptionalDescription
contentContentAPINoContent read/write. Specific methods are gated by capabilities:
networkNetworkAPIYesOutbound HTTP. Only available if `capabilities.network` is declared.
settingsSettingsAPINoEncrypted key-value store scoped to this plugin + tenant.
loggerPluginLoggerNoStructured logging. Always available.

Plugin kill-switch behavior

StateHow it's triggeredEffectRecovery
enabled=trueDefault after installHooks fire on every matching event
enabled=false (manual)Admin toggles off in Settings → PluginsHook loader skips all handlers for this pluginAdmin toggles on
enabled=false (auto)3 consecutive hook failures in any 24h windowVeloCMS auto-disables + sends email to tenant adminAdmin acknowledges + re-enables
deletedAdmin uninstalls the pluginPlugin entry removed from site_settings.installed_pluginsRe-install from marketplace

Sandbox limits

LimitDefaultConfigurableExceeded behavior
Memory per isolate32 MBNo (platform policy)Isolate terminated; hook chain continues without this plugin
CPU time per hook5 secondsNo (platform policy)Isolate terminated with timeout error in execution log
Outbound fetch URLsAllowlist only (manifest)Yes (per-plugin manifest)CapabilityError thrown; fetch returns rejected promise
Concurrent hook calls1 per plugin per tenantNoQueue — second call waits until first resolves

Plugin manifest — minimal required fields

manifest.json (minimum valid)
{
  "id": "your-org.plugin-name",
  "name": "Human Readable Name",
  "version": "1.0.0",
  "description": "One sentence.",
  "author": { "name": "Your Name", "email": "you@example.com" },
  "capabilities": [],
  "hooks": ["afterPostPublish"],
  "builtin": false,
  "engines": { "velocms": ">=1.0.0" },
  "pricing": { "model": "free" }
}
json

Hook handler TypeScript signature

Hook handler function 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)
}
typescript

Testing with mockVelocms — full API

Full mockVelocms capabilities
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();
typescript