Plugin Development — Advanced

14 min readUpdated 29 Apr 2026

The introductory Plugin Development article covers the sandbox model, hook API, and a hello-world example. This article goes deeper: the sandbox lifecycle pool that keeps cold-start latency under 20ms, the AST scanner that blocks dangerous code patterns before a plugin ever runs, the CLI scaffolding tool, and advanced patterns for plugins that need to call external APIs or return modified post content.

Sandbox lifecycle pool (PL2.2)

Creating a new isolated-vm Isolate on every hook invocation costs roughly 80-150ms of cold-start time. For a plugin responding to afterPostPublish, that overhead is invisible to the user. But for hooks that block the publish flow (beforePostPublish), that latency is paid synchronously. The sandbox pool solves this by pre-warming a configurable number of isolates per installed plugin and returning them from a pool rather than creating on demand.

The pool maintains a fixed-size LIFO stack of Isolate handles per plugin ID. On hook invocation: if the pool has an available isolate, it is checked out and returned after execution. If the pool is empty (concurrent calls exhausted it), a new isolate is created on the spot (cold path). After execution, the isolate is reset and returned to the pool — the plugin's module-level state is cleared but the code itself does not need to be recompiled. This keeps warm-path latency under 20ms for most plugins.

PathLatencyWhen it occurs
Warm (pool hit)< 20msIsolate available in pool — covers most normal-traffic cases
Cold (pool miss)80–150msPool exhausted by concurrent calls, or first invocation after deploy
Timeout5000msPlugin exceeded CPU time limit — isolate is terminated
Conceptual pool usage (src/lib/plugins/sandbox/pool.ts)
// The pool API is internal — plugin developers don't interact with it directly.
// This shows how the hook runner uses it:

const isolate = await pool.checkout(pluginId);
try {
  const result = await isolate.run(hookName, payload, { timeout: 5000 });
  return result;
} finally {
  pool.checkin(pluginId, isolate); // returns isolate to pool after reset
}
typescript

AST security scan (PL2.3)

Before any plugin code runs for the first time after install or update, VeloCMS runs a static AST analysis pass over the plugin's compiled JavaScript. The scan uses Acorn to parse the code and walks the AST looking for patterns that indicate sandbox escape attempts or capability violations. A plugin that fails the scan is rejected at install time — it never runs.

The scanner checks for: calls to eval() or new Function(), access to global.process, global.__dirname, or global.__filename, use of require() or import() with any argument (dynamic imports), access to buffer or Buffer constructor, network calls that do not go through velocms.network.fetch, and filesystem API references (fs, path). Obfuscated code that matches known sandbox-escape patterns is also flagged.

PatternWhy it's blockedError code
eval() / new Function()Dynamic code execution can escape sandboxSEC-001
process.env accessExposes host environment variablesSEC-002
require() / import()Node module access bypasses capability manifestSEC-003
fs.* / path.*Filesystem access is not available in the sandboxSEC-004
Buffer() constructorBuffer can be used to read arbitrary memory in some V8 versionsSEC-005
XMLHttpRequest / fetch (global)Network must go through velocms.network.fetch onlySEC-006

create-velocms-plugin CLI

The fastest way to start a new plugin is the scaffolding CLI. It generates a project directory with all required files: manifest.json with your metadata, index.ts with a typed hook handler skeleton, index.test.ts with a mockVelocms test, tsconfig.json configured for the SDK's target environment, and a package.json with the build and test scripts.

Create a new plugin project
npx create-velocms-plugin@latest

# The CLI prompts for:
#   Plugin ID (e.g., my-org.seo-optimizer)
#   Display name (e.g., SEO Optimizer)
#   Description (1 sentence)
#   Author name + email
#   Hooks to handle (checkbox list)
#   Capabilities needed (checkbox list)
#   Pricing model (free / paid)

# Creates:
#   my-org.seo-optimizer/
#     manifest.json
#     src/
#       index.ts
#       index.test.ts
#     tsconfig.json
#     package.json
#     README.md
bash
Generated package.json
{
  "name": "@my-org/seo-optimizer",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "dev": "vitest watch"
  },
  "devDependencies": {
    "@velocms/plugin-sdk": "^1.0.0",
    "typescript": "^5.0.0",
    "vitest": "^2.0.0"
  }
}
json

Pattern: calling external APIs

Plugins that need to call external APIs must declare the network capability in their manifest and list the allowed URL prefixes in the allowedUrls array. Any URL not in the allowlist causes a CapabilityError at runtime. The velocms.network.fetch function has the same signature as the standard Fetch API, but requests that do not match an allowed prefix are blocked before leaving the sandbox.

External API call pattern
// manifest.json excerpt:
// "capabilities": ["content:read", "network"],
// "networkAllowlist": ["https://api.openai.com/"]

import type { VelocmsPluginAPI, Post } from "@velocms/plugin-sdk";

export async function beforePostPublish(
  payload: { post: Post },
  velocms: VelocmsPluginAPI
): Promise<Partial<{ post: Post }>> {
  const res = await velocms.network.fetch(
    "https://api.openai.com/v1/chat/completions",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${await velocms.settings.get("my-plugin.api-key")}`,
      },
      body: JSON.stringify({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: `Suggest SEO improvements for: ${payload.post.title}` }],
      }),
    }
  );

  const data = await res.json();
  const suggestion = data.choices?.[0]?.message?.content ?? "";

  // Attach SEO suggestion as a custom field and return modified post
  return {
    post: {
      ...payload.post,
      seo_notes: suggestion.slice(0, 500),
    },
  };
}
typescript

Pattern: storing persistent state

Plugin isolates are ephemeral — module-level variables reset between invocations. For state that must survive across hook calls (counters, caches, rate-limit windows), use velocms.settings.set() and velocms.settings.get() with plugin-scoped keys. The settings API persists data in the tenant's site_settings record in PocketBase. Values must be JSON-serializable strings.

Persistent counter pattern
export async function afterPostPublish(
  payload: { post: { id: string } },
  velocms: VelocmsPluginAPI
): Promise<void> {
  // Read current count (null if first publish)
  const raw = await velocms.settings.get("my-plugin.publish-count");
  const count = raw ? parseInt(raw, 10) : 0;

  // Increment and save
  await velocms.settings.set("my-plugin.publish-count", String(count + 1));

  if ((count + 1) % 10 === 0) {
    velocms.logger.info("Milestone reached", { publishCount: count + 1 });
  }
}
typescript

Pattern: modifying post content

Only beforePostPublish and beforePostCreate hooks can return a modified post payload. The return value is a Partial of the post object — only the fields you include are merged into the original. You do not need to return the full post, just the fields you changed. Returning void (or undefined) from these hooks tells VeloCMS to use the original payload unchanged.

Post content modification pattern (beforePostPublish)
import type { VelocmsPluginAPI, Post } from "@velocms/plugin-sdk";

export async function beforePostPublish(
  payload: { post: Post },
  velocms: VelocmsPluginAPI
): Promise<Partial<{ post: Post }> | void> {
  const { post } = payload;

  // Only add reading time if not already set
  if (post.reading_time_minutes) return; // void = use original

  const wordCount = post.content
    ? post.content.replace(/<[^>]*>/g, "").split(/s+/).length
    : 0;
  const readingTime = Math.max(1, Math.round(wordCount / 200));

  // Return only the fields that changed
  return {
    post: { reading_time_minutes: readingTime },
  };
}
typescript

Debugging plugins locally

The isolated-vm sandbox suppresses console.log output to prevent information leakage. To debug during development, use velocms.logger.info() — in test mode this writes to stdout. In the real sandbox, logger output goes to the plugin execution log which you can view in Admin → Settings → Plugins → Execution Log for each installed plugin.

Local development workflow
# In your plugin project directory:

# Run tests with watch mode
npm run dev

# Build TypeScript to dist/index.js
npm run build

# Test with the real SDK mock
npm test

# Before submitting, verify the AST scan would pass:
# (the SDK ships a local scan utility)
npx velocms-plugin-scan ./dist/index.js
bash

Marketplace submission

Submissions go through a two-stage review: automated AST scan + capability manifest consistency check (does the code actually use what it declared, and nothing more), followed by human review for UX quality, description accuracy, and pricing honesty. Plan for 3-7 business days on first submission. Updates to existing published plugins are reviewed faster (usually 1-2 business days) if the capability manifest does not expand.

  • Build your plugin to dist/index.js via npm run build
  • Create a ZIP containing: manifest.json, dist/index.js, and optionally README.md and screenshots/
  • Go to velocms.org/developers → Submit a Plugin
  • Upload the ZIP and fill in the marketplace listing form
  • Automated scan runs immediately — you'll see results within 60 seconds
  • If scan passes, the plugin enters the human review queue
  • You receive an email when approved, rejected, or when a reviewer leaves comments

Reference

File / ResourcePurpose
src/lib/plugins/builtin/word-counter.tsFull working built-in plugin — 80 lines showing the complete pattern
src/lib/plugins/sandbox/pool.tsIsolate lifecycle pool implementation (PL2.2)
src/lib/plugins/review-pipeline.tsAST scan + capability consistency check (PL2.3)
@velocms/plugin-sdknpm package: VelocmsPluginAPI types, createMockVelocms
plugin-developmentIntroductory plugin article — sandbox model, hello world